feat: implement dea module

This commit is contained in:
ws50529
2026-05-14 09:41:00 +08:00
parent edec4e6db6
commit a9489dafe4
14 changed files with 873 additions and 679 deletions
+2
View File
@@ -0,0 +1,2 @@
"""Core package for the Difference Equation Analyzer API."""
Executable
+26
View File
@@ -0,0 +1,26 @@
import numpy as np
from scipy import signal
from .config import BODE_MAX_MULTIPLIER, BODE_POINTS
from .schemas import BodeParams
from .validation import require_finite, validate_coefficients
def calculate_bode_response(params: BodeParams):
fs_val = require_finite(params.fs, "fs")
b_vals = validate_coefficients(params.b, "b")
a_vals = validate_coefficients(params.a, "a")
f_min = params.fs / 50000.0
f_max = params.fs * BODE_MAX_MULTIPLIER
f_eval = np.logspace(np.log10(f_min), np.log10(f_max), BODE_POINTS)
_, h = signal.freqz(b_vals, a_vals, worN=f_eval, fs=fs_val)
mag = 20 * np.log10(np.abs(h) + 1e-12)
phase = np.angle(h, deg=True)
return {
"freq": f_eval.tolist(),
"mag": mag.tolist(),
"phase": phase.tolist(),
}
Executable
+7
View File
@@ -0,0 +1,7 @@
MAX_COEFFICIENTS = 64
MAX_CSV_BYTES = 32 * 1024 * 1024
MAX_PLOT_POINTS = 5000
BODE_POINTS = 500
BODE_MAX_MULTIPLIER = 3.162
NYQUIST_MARGIN = 0.999999
+72
View File
@@ -0,0 +1,72 @@
import io
import numpy as np
import pandas as pd
from fastapi import HTTPException
from scipy import signal
from .config import MAX_CSV_BYTES, MAX_PLOT_POINTS
def downsample_for_plot(index, original, filtered):
total = len(index)
if total <= MAX_PLOT_POINTS:
return index, original, filtered, 1
step = int(np.ceil(total / MAX_PLOT_POINTS))
return index[::step], original[::step], filtered[::step], step
async def read_csv_upload(file):
filename = (file.filename or "").lower()
if filename and not filename.endswith(".csv"):
raise HTTPException(status_code=400, detail="請上傳 CSV 檔案")
contents = await file.read(MAX_CSV_BYTES + 1)
if len(contents) > MAX_CSV_BYTES:
raise HTTPException(status_code=413, detail=f"CSV 檔案不可超過 {MAX_CSV_BYTES // (1024 * 1024)}MB")
if not contents.strip():
raise HTTPException(status_code=400, detail="CSV 不可為空")
try:
df = pd.read_csv(io.BytesIO(contents))
except Exception as e:
raise HTTPException(status_code=400, detail=f"CSV 讀取失敗: {str(e)}")
if df.empty:
raise HTTPException(status_code=400, detail="CSV 不可為空")
return df
def filtered_csv_data(df, b_vals, a_vals, col_idx):
if col_idx < 0 or len(df.columns) <= col_idx:
raise HTTPException(status_code=400, detail="欄位索引超出範圍")
col_to_filter = df.columns[col_idx]
x_signal = pd.to_numeric(df[col_to_filter], errors="coerce")
if x_signal.isna().any():
raise HTTPException(status_code=400, detail=f"欄位 {col_to_filter} 含有非數值資料")
x_values = x_signal.to_numpy(dtype=float)
if not np.isfinite(x_values).all():
raise HTTPException(status_code=400, detail=f"欄位 {col_to_filter} 含有非有限數值")
y_signal = signal.lfilter(b_vals, a_vals, x_values)
return col_to_filter, x_values, y_signal
def filter_preview_response(df, b_vals, a_vals, col_idx):
col_to_filter, x_signal, y_signal = filtered_csv_data(df, b_vals, a_vals, col_idx)
index, original, filtered, step = downsample_for_plot(df.index.to_numpy(), x_signal, y_signal)
return {
"index": index.tolist(),
"original": original.tolist(),
"filtered": filtered.tolist(),
"col_name": col_to_filter,
"total_points": int(len(df.index)),
"plot_points": int(len(index)),
"downsample_step": int(step),
}
def filtered_csv_text(df, b_vals, a_vals, col_idx):
col_to_filter, _, y_signal = filtered_csv_data(df, b_vals, a_vals, col_idx)
df[f"{col_to_filter}_filtered"] = y_signal
csv_buffer = io.StringIO()
df.to_csv(csv_buffer, index=False)
return csv_buffer.getvalue()
+92
View File
@@ -0,0 +1,92 @@
import numpy as np
from fastapi import HTTPException
from scipy import signal
from .schemas import DesignParams
from .validation import normalize_coefficients, require_finite, validate_frequency
def _normalize_dc_gain(b_values, a_values):
dc_gain = np.sum(b_values) / np.sum(a_values)
return b_values / dc_gain
def design_coefficients(params: DesignParams):
fs_val = require_finite(params.fs, "fs")
f_type = params.filter_type
if f_type == "Lowpass (低通)":
validate_frequency("低通截止頻率", params.lp_fc, fs_val)
b_new, a_new = signal.butter(params.lp_order, params.lp_fc, btype="low", fs=fs_val)
elif f_type == "Highpass (高通)":
validate_frequency("高通截止頻率", params.hp_fc, fs_val)
b_new, a_new = signal.butter(params.hp_order, params.hp_fc, btype="high", fs=fs_val)
elif f_type == "Bandpass (帶通)":
f_low, f_high = params.bp_f_low, params.bp_f_high
validate_frequency("帶通下截止頻率", f_low, fs_val)
validate_frequency("帶通上截止頻率", f_high, fs_val)
if f_low >= f_high:
raise HTTPException(status_code=400, detail="帶通下截止頻率必須小於上截止頻率")
b_new, a_new = signal.butter(params.bp_order, [f_low, f_high], btype="bandpass", fs=fs_val)
elif f_type == "Notch (陷波器)":
validate_frequency("陷波中心頻率", params.notch_f0, fs_val)
b_new, a_new = signal.iirnotch(params.notch_f0, params.notch_q, fs=fs_val)
elif f_type == "1P1Z (一極一零)":
validate_frequency("1P1Z 零點頻率", params.opoz_fz, fs_val, below_nyquist=False)
validate_frequency("1P1Z 極點頻率", params.opoz_fp, fs_val, below_nyquist=False)
b_s = [1.0, 2 * np.pi * params.opoz_fz]
a_s = [1.0, 2 * np.pi * params.opoz_fp]
b_new, a_new = signal.bilinear(b_s, a_s, fs=fs_val)
b_new = _normalize_dc_gain(b_new, a_new)
elif f_type == "2P1Z (二極一零)":
validate_frequency("2P1Z 零點頻率", params.tp1z_fz, fs_val, below_nyquist=False)
validate_frequency("2P1Z 極點頻率 1", params.tp1z_fp1, fs_val, below_nyquist=False)
validate_frequency("2P1Z 極點頻率 2", params.tp1z_fp2, fs_val, below_nyquist=False)
w_z = 2 * np.pi * params.tp1z_fz
w_p1, w_p2 = 2 * np.pi * params.tp1z_fp1, 2 * np.pi * params.tp1z_fp2
b_s = [1.0, w_z]
a_s = [1.0, w_p1 + w_p2, w_p1 * w_p2]
b_new, a_new = signal.bilinear(b_s, a_s, fs=fs_val)
b_new = _normalize_dc_gain(b_new, a_new)
elif f_type == "2P2Z (二極二零)":
validate_frequency("2P2Z 極點頻率 1", params.tptz_fp1, fs_val, below_nyquist=False)
validate_frequency("2P2Z 極點頻率 2", params.tptz_fp2, fs_val, below_nyquist=False)
validate_frequency("2P2Z 零點頻率 1", params.tptz_fz1, fs_val, below_nyquist=False)
validate_frequency("2P2Z 零點頻率 2", params.tptz_fz2, fs_val, below_nyquist=False)
w_p1, w_p2 = 2 * np.pi * params.tptz_fp1, 2 * np.pi * params.tptz_fp2
w_z1, w_z2 = 2 * np.pi * params.tptz_fz1, 2 * np.pi * params.tptz_fz2
b_s = [1.0, w_z1 + w_z2, w_z1 * w_z2]
a_s = [1.0, w_p1 + w_p2, w_p1 * w_p2]
b_new, a_new = signal.bilinear(b_s, a_s, fs=fs_val)
b_new = _normalize_dc_gain(b_new, a_new)
elif f_type == "PID 控制器":
ki_term = params.pid_ki / (2.0 * fs_val)
kd_term = params.pid_kd * fs_val
b_new = [
params.pid_kp + ki_term + kd_term,
-params.pid_kp + ki_term - 2.0 * kd_term,
kd_term,
]
a_new = [1.0, -1.0, 0.0]
elif f_type == "SOGI-Alpha (帶通)":
validate_frequency("SOGI 中心頻率", params.sogi_f0, fs_val)
w0 = 2 * np.pi * params.sogi_f0
b_s = [params.sogi_k * w0, 0.0]
a_s = [1.0, params.sogi_k * w0, w0**2]
b_new, a_new = signal.bilinear(b_s, a_s, fs=fs_val)
elif f_type == "SOGI-Beta (低通)":
validate_frequency("SOGI 中心頻率", params.sogi_f0, fs_val)
w0 = 2 * np.pi * params.sogi_f0
b_s = [params.sogi_k * (w0**2)]
a_s = [1.0, params.sogi_k * w0, w0**2]
b_new, a_new = signal.bilinear(b_s, a_s, fs=fs_val)
else:
raise HTTPException(status_code=400, detail="未知的濾波器類型")
return normalize_coefficients(b_new, a_new)
def design_response(params: DesignParams):
b_arr, a_arr = design_coefficients(params)
return {"b": b_arr.tolist(), "a": a_arr.tolist()}
Executable
+40
View File
@@ -0,0 +1,40 @@
from typing import List
from pydantic import BaseModel, Field
from .config import MAX_COEFFICIENTS
class DesignParams(BaseModel):
filter_type: str
fs: float = Field(gt=0)
lp_fc: float = Field(default=1000.0, gt=0)
lp_order: int = Field(default=1, ge=1, le=8)
hp_fc: float = Field(default=1000.0, gt=0)
hp_order: int = Field(default=1, ge=1, le=8)
bp_f_low: float = Field(default=500.0, gt=0)
bp_f_high: float = Field(default=2000.0, gt=0)
bp_order: int = Field(default=1, ge=1, le=8)
notch_f0: float = Field(default=120.0, gt=0)
notch_q: float = Field(default=1.0, gt=0)
opoz_fz: float = Field(default=15000.0, gt=0)
opoz_fp: float = Field(default=10.0, gt=0)
tp1z_fz: float = Field(default=200.0, gt=0)
tp1z_fp1: float = Field(default=10.0, gt=0)
tp1z_fp2: float = Field(default=5000.0, gt=0)
tptz_fz1: float = Field(default=200.0, gt=0)
tptz_fz2: float = Field(default=25000.0, gt=0)
tptz_fp1: float = Field(default=10.0, gt=0)
tptz_fp2: float = Field(default=5000.0, gt=0)
pid_kp: float = 0.003
pid_ki: float = 10.0
pid_kd: float = 0.000016
sogi_f0: float = Field(default=60.0, gt=0)
sogi_k: float = Field(default=1.414, gt=0)
class BodeParams(BaseModel):
b: List[float] = Field(min_length=1, max_length=MAX_COEFFICIENTS)
a: List[float] = Field(min_length=1, max_length=MAX_COEFFICIENTS)
fs: float = Field(gt=0)
+46
View File
@@ -0,0 +1,46 @@
import ipaddress
from fastapi.responses import JSONResponse
SECURITY_HEADERS = {
"Content-Security-Policy": (
"default-src 'self'; "
"script-src 'self' https://cdn.plot.ly; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"connect-src 'self'; "
"font-src 'self' data:; "
"object-src 'none'; "
"base-uri 'self'; "
"frame-ancestors 'none'"
),
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
}
def add_security_headers(response):
for key, value in SECURITY_HEADERS.items():
response.headers[key] = value
return response
def is_lan_or_loopback(host):
if not host:
return True
try:
ip_obj = ipaddress.ip_address(host)
except ValueError:
return True
return ip_obj.is_private or ip_obj.is_loopback
def lan_denied_response():
return add_security_headers(
JSONResponse(
status_code=403,
content={"detail": "Access Denied: Only LAN connections are allowed."},
)
)
+50
View File
@@ -0,0 +1,50 @@
import numpy as np
from fastapi import HTTPException
from .config import MAX_COEFFICIENTS, NYQUIST_MARGIN
def require_finite(value, name):
if not np.isfinite(value):
raise HTTPException(status_code=400, detail=f"{name} 必須是有限數值")
return float(value)
def validate_frequency(name, value, fs, *, below_nyquist=True):
value = require_finite(value, name)
if value <= 0:
raise HTTPException(status_code=400, detail=f"{name} 必須大於 0")
if below_nyquist and value >= (fs / 2) * NYQUIST_MARGIN:
raise HTTPException(status_code=400, detail=f"{name} 必須小於 Nyquist 頻率 ({fs / 2:g} Hz)")
return value
def parse_coefficients(raw, name):
try:
values = [float(x.strip()) for x in raw.replace(",", " ").split() if x.strip()]
except ValueError:
raise HTTPException(status_code=400, detail=f"{name} 係數格式錯誤")
return validate_coefficients(values, name)
def validate_coefficients(values, name):
if len(values) == 0:
raise HTTPException(status_code=400, detail=f"{name} 係數不可為空")
if len(values) > MAX_COEFFICIENTS:
raise HTTPException(status_code=400, detail=f"{name} 係數最多 {MAX_COEFFICIENTS}")
coeffs = np.asarray(values, dtype=float)
if not np.all(np.isfinite(coeffs)):
raise HTTPException(status_code=400, detail=f"{name} 係數必須都是有限數值")
if name == "a" and np.isclose(coeffs[0], 0.0):
raise HTTPException(status_code=400, detail="a[0] 不可為 0")
return coeffs
def normalize_coefficients(b_values, a_values):
b_arr = validate_coefficients(b_values, "b")
a_arr = validate_coefficients(a_values, "a")
if not np.isclose(a_arr[0], 1.0):
b_arr = b_arr / a_arr[0]
a_arr = a_arr / a_arr[0]
return b_arr, a_arr
+51 -271
View File
@@ -1,335 +1,115 @@
import numpy as np
import pandas as pd
from scipy import signal
from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Request
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import RedirectResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from typing import List
import io
from fastapi.responses import StreamingResponse, RedirectResponse, JSONResponse
import ipaddress
from dea.bode import calculate_bode_response
from dea.config import (
BODE_MAX_MULTIPLIER,
BODE_POINTS,
MAX_COEFFICIENTS,
MAX_CSV_BYTES,
MAX_PLOT_POINTS,
NYQUIST_MARGIN,
)
from dea.csv_processing import (
downsample_for_plot,
filter_preview_response,
filtered_csv_data,
filtered_csv_text,
read_csv_upload,
)
from dea.filter_design import design_response
from dea.schemas import BodeParams, DesignParams
from dea.security import (
SECURITY_HEADERS,
add_security_headers,
is_lan_or_loopback,
lan_denied_response,
)
from dea.validation import (
normalize_coefficients,
parse_coefficients,
require_finite,
validate_coefficients,
validate_frequency,
)
app = FastAPI(title="Difference Equation Analyzer API")
MAX_COEFFICIENTS = 64
MAX_CSV_BYTES = 32 * 1024 * 1024
MAX_PLOT_POINTS = 5000
BODE_POINTS = 500
BODE_MAX_MULTIPLIER = 3.162
NYQUIST_MARGIN = 0.999999
SECURITY_HEADERS = {
"Content-Security-Policy": (
"default-src 'self'; "
"script-src 'self' https://cdn.plot.ly; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"connect-src 'self'; "
"font-src 'self' data:; "
"object-src 'none'; "
"base-uri 'self'; "
"frame-ancestors 'none'"
),
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
}
def add_security_headers(response):
for key, value in SECURITY_HEADERS.items():
response.headers[key] = value
return response
@app.middleware("http")
async def restrict_to_lan(request: Request, call_next):
client_ip = request.client.host
if client_ip:
try:
ip_obj = ipaddress.ip_address(client_ip)
if not (ip_obj.is_private or ip_obj.is_loopback):
return add_security_headers(JSONResponse(status_code=403, content={"detail": "Access Denied: Only LAN connections are allowed."}))
except ValueError:
pass
client_ip = request.client.host if request.client else None
if not is_lan_or_loopback(client_ip):
return lan_denied_response()
response = await call_next(request)
return add_security_headers(response)
# 掛載靜態網頁檔案,將預設首頁導向到 /ui/
app.mount("/ui", StaticFiles(directory="static", html=True), name="static")
@app.get("/")
def redirect_to_ui():
return RedirectResponse(url="/ui/")
class DesignParams(BaseModel):
filter_type: str
fs: float = Field(gt=0)
lp_fc: float = Field(default=1000.0, gt=0)
lp_order: int = Field(default=1, ge=1, le=8)
hp_fc: float = Field(default=1000.0, gt=0)
hp_order: int = Field(default=1, ge=1, le=8)
bp_f_low: float = Field(default=500.0, gt=0)
bp_f_high: float = Field(default=2000.0, gt=0)
bp_order: int = Field(default=1, ge=1, le=8)
notch_f0: float = Field(default=120.0, gt=0)
notch_q: float = Field(default=1.0, gt=0)
opoz_fz: float = Field(default=15000.0, gt=0)
opoz_fp: float = Field(default=10.0, gt=0)
tp1z_fz: float = Field(default=200.0, gt=0)
tp1z_fp1: float = Field(default=10.0, gt=0)
tp1z_fp2: float = Field(default=5000.0, gt=0)
tptz_fz1: float = Field(default=200.0, gt=0)
tptz_fz2: float = Field(default=25000.0, gt=0)
tptz_fp1: float = Field(default=10.0, gt=0)
tptz_fp2: float = Field(default=5000.0, gt=0)
pid_kp: float = 0.003
pid_ki: float = 10.0
pid_kd: float = 0.000016
sogi_f0: float = Field(default=60.0, gt=0)
sogi_k: float = Field(default=1.414, gt=0)
class BodeParams(BaseModel):
b: List[float] = Field(min_length=1, max_length=MAX_COEFFICIENTS)
a: List[float] = Field(min_length=1, max_length=MAX_COEFFICIENTS)
fs: float = Field(gt=0)
def require_finite(value, name):
if not np.isfinite(value):
raise HTTPException(status_code=400, detail=f"{name} 必須是有限數值")
return float(value)
def validate_frequency(name, value, fs, *, below_nyquist=True):
value = require_finite(value, name)
if value <= 0:
raise HTTPException(status_code=400, detail=f"{name} 必須大於 0")
if below_nyquist and value >= (fs / 2) * NYQUIST_MARGIN:
raise HTTPException(status_code=400, detail=f"{name} 必須小於 Nyquist 頻率 ({fs / 2:g} Hz)")
return value
def parse_coefficients(raw, name):
try:
values = [float(x.strip()) for x in raw.replace(',', ' ').split() if x.strip()]
except ValueError:
raise HTTPException(status_code=400, detail=f"{name} 係數格式錯誤")
return validate_coefficients(values, name)
def validate_coefficients(values, name):
if len(values) == 0:
raise HTTPException(status_code=400, detail=f"{name} 係數不可為空")
if len(values) > MAX_COEFFICIENTS:
raise HTTPException(status_code=400, detail=f"{name} 係數最多 {MAX_COEFFICIENTS}")
coeffs = np.asarray(values, dtype=float)
if not np.all(np.isfinite(coeffs)):
raise HTTPException(status_code=400, detail=f"{name} 係數必須都是有限數值")
if name == "a" and np.isclose(coeffs[0], 0.0):
raise HTTPException(status_code=400, detail="a[0] 不可為 0")
return coeffs
def normalize_coefficients(b_values, a_values):
b_arr = validate_coefficients(b_values, "b")
a_arr = validate_coefficients(a_values, "a")
if not np.isclose(a_arr[0], 1.0):
b_arr = b_arr / a_arr[0]
a_arr = a_arr / a_arr[0]
return b_arr, a_arr
def downsample_for_plot(index, original, filtered):
total = len(index)
if total <= MAX_PLOT_POINTS:
return index, original, filtered, 1
step = int(np.ceil(total / MAX_PLOT_POINTS))
return index[::step], original[::step], filtered[::step], step
async def read_csv_upload(file):
filename = (file.filename or "").lower()
if filename and not filename.endswith(".csv"):
raise HTTPException(status_code=400, detail="請上傳 CSV 檔案")
contents = await file.read(MAX_CSV_BYTES + 1)
if len(contents) > MAX_CSV_BYTES:
raise HTTPException(status_code=413, detail=f"CSV 檔案不可超過 {MAX_CSV_BYTES // (1024 * 1024)}MB")
if not contents.strip():
raise HTTPException(status_code=400, detail="CSV 不可為空")
try:
df = pd.read_csv(io.BytesIO(contents))
except Exception as e:
raise HTTPException(status_code=400, detail=f"CSV 讀取失敗: {str(e)}")
if df.empty:
raise HTTPException(status_code=400, detail="CSV 不可為空")
return df
def filtered_csv_data(df, b_vals, a_vals, col_idx):
if col_idx < 0 or len(df.columns) <= col_idx:
raise HTTPException(status_code=400, detail="欄位索引超出範圍")
col_to_filter = df.columns[col_idx]
x_signal = pd.to_numeric(df[col_to_filter], errors="coerce")
if x_signal.isna().any():
raise HTTPException(status_code=400, detail=f"欄位 {col_to_filter} 含有非數值資料")
x_values = x_signal.to_numpy(dtype=float)
if not np.isfinite(x_values).all():
raise HTTPException(status_code=400, detail=f"欄位 {col_to_filter} 含有非有限數值")
y_signal = signal.lfilter(b_vals, a_vals, x_values)
return col_to_filter, x_values, y_signal
@app.post("/api/design")
def design_filter(params: DesignParams):
fs_val = require_finite(params.fs, "fs")
f_type = params.filter_type
try:
if f_type == "Lowpass (低通)":
validate_frequency("低通截止頻率", params.lp_fc, fs_val)
b_new, a_new = signal.butter(params.lp_order, params.lp_fc, btype='low', fs=fs_val)
elif f_type == "Highpass (高通)":
validate_frequency("高通截止頻率", params.hp_fc, fs_val)
b_new, a_new = signal.butter(params.hp_order, params.hp_fc, btype='high', fs=fs_val)
elif f_type == "Bandpass (帶通)":
f_low, f_high = params.bp_f_low, params.bp_f_high
validate_frequency("帶通下截止頻率", f_low, fs_val)
validate_frequency("帶通上截止頻率", f_high, fs_val)
if f_low >= f_high:
raise HTTPException(status_code=400, detail="帶通下截止頻率必須小於上截止頻率")
b_new, a_new = signal.butter(params.bp_order, [f_low, f_high], btype='bandpass', fs=fs_val)
elif f_type == "Notch (陷波器)":
validate_frequency("陷波中心頻率", params.notch_f0, fs_val)
b_new, a_new = signal.iirnotch(params.notch_f0, params.notch_q, fs=fs_val)
elif f_type == "1P1Z (一極一零)":
validate_frequency("1P1Z 零點頻率", params.opoz_fz, fs_val, below_nyquist=False)
validate_frequency("1P1Z 極點頻率", params.opoz_fp, fs_val, below_nyquist=False)
b_s = [1.0, 2 * np.pi * params.opoz_fz]
a_s = [1.0, 2 * np.pi * params.opoz_fp]
b_new, a_new = signal.bilinear(b_s, a_s, fs=fs_val)
dc_gain = np.sum(b_new) / np.sum(a_new)
b_new = b_new / dc_gain
elif f_type == "2P1Z (二極一零)":
validate_frequency("2P1Z 零點頻率", params.tp1z_fz, fs_val, below_nyquist=False)
validate_frequency("2P1Z 極點頻率 1", params.tp1z_fp1, fs_val, below_nyquist=False)
validate_frequency("2P1Z 極點頻率 2", params.tp1z_fp2, fs_val, below_nyquist=False)
w_z = 2 * np.pi * params.tp1z_fz
w_p1, w_p2 = 2 * np.pi * params.tp1z_fp1, 2 * np.pi * params.tp1z_fp2
b_s = [1.0, w_z]
a_s = [1.0, w_p1 + w_p2, w_p1 * w_p2]
b_new, a_new = signal.bilinear(b_s, a_s, fs=fs_val)
dc_gain = np.sum(b_new) / np.sum(a_new)
b_new = b_new / dc_gain
elif f_type == "2P2Z (二極二零)":
validate_frequency("2P2Z 極點頻率 1", params.tptz_fp1, fs_val, below_nyquist=False)
validate_frequency("2P2Z 極點頻率 2", params.tptz_fp2, fs_val, below_nyquist=False)
validate_frequency("2P2Z 零點頻率 1", params.tptz_fz1, fs_val, below_nyquist=False)
validate_frequency("2P2Z 零點頻率 2", params.tptz_fz2, fs_val, below_nyquist=False)
w_p1, w_p2 = 2 * np.pi * params.tptz_fp1, 2 * np.pi * params.tptz_fp2
w_z1, w_z2 = 2 * np.pi * params.tptz_fz1, 2 * np.pi * params.tptz_fz2
b_s = [1.0, w_z1 + w_z2, w_z1 * w_z2]
a_s = [1.0, w_p1 + w_p2, w_p1 * w_p2]
b_new, a_new = signal.bilinear(b_s, a_s, fs=fs_val)
dc_gain = np.sum(b_new) / np.sum(a_new)
b_new = b_new / dc_gain
elif f_type == "PID 控制器":
ki_term = params.pid_ki / (2.0 * fs_val)
kd_term = params.pid_kd * fs_val
b0 = params.pid_kp + ki_term + kd_term
b1 = -params.pid_kp + ki_term - 2.0 * kd_term
b2 = kd_term
b_new = [b0, b1, b2]
a_new = [1.0, -1.0, 0.0]
elif f_type == "SOGI-Alpha (帶通)":
validate_frequency("SOGI 中心頻率", params.sogi_f0, fs_val)
w0 = 2 * np.pi * params.sogi_f0
b_s = [params.sogi_k * w0, 0.0]
a_s = [1.0, params.sogi_k * w0, w0**2]
b_new, a_new = signal.bilinear(b_s, a_s, fs=fs_val)
elif f_type == "SOGI-Beta (低通)":
validate_frequency("SOGI 中心頻率", params.sogi_f0, fs_val)
w0 = 2 * np.pi * params.sogi_f0
b_s = [params.sogi_k * (w0**2)]
a_s = [1.0, params.sogi_k * w0, w0**2]
b_new, a_new = signal.bilinear(b_s, a_s, fs=fs_val)
else:
raise HTTPException(status_code=400, detail="未知的濾波器類型")
b_arr, a_arr = normalize_coefficients(b_new, a_new)
return {"b": b_arr.tolist(), "a": a_arr.tolist()}
return design_response(params)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@app.post("/api/bode")
def calculate_bode(params: BodeParams):
try:
fs_val = require_finite(params.fs, "fs")
b_vals = validate_coefficients(params.b, "b")
a_vals = validate_coefficients(params.a, "a")
f_min = params.fs / 50000.0
f_max = params.fs * BODE_MAX_MULTIPLIER # 修復為 Legacy 版本的顯示範圍上限 (原為 params.fs)
f_eval = np.logspace(np.log10(f_min), np.log10(f_max), BODE_POINTS)
# WorN determines frequencies to evaluate at
w, h = signal.freqz(b_vals, a_vals, worN=f_eval, fs=fs_val)
mag = 20 * np.log10(np.abs(h) + 1e-12)
phase = np.angle(h, deg=True)
return {
"freq": f_eval.tolist(),
"mag": mag.tolist(),
"phase": phase.tolist()
}
return calculate_bode_response(params)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@app.post("/api/filter")
async def filter_csv(
file: UploadFile = File(...),
b: str = Form(...),
a: str = Form(...),
col_idx: int = Form(0)
col_idx: int = Form(0),
):
try:
b_vals = parse_coefficients(b, "b")
a_vals = parse_coefficients(a, "a")
df = await read_csv_upload(file)
col_to_filter, x_signal, y_signal = filtered_csv_data(df, b_vals, a_vals, col_idx)
index, original, filtered, step = downsample_for_plot(df.index.to_numpy(), x_signal, y_signal)
return {
"index": index.tolist(),
"original": original.tolist(),
"filtered": filtered.tolist(),
"col_name": col_to_filter,
"total_points": int(len(df.index)),
"plot_points": int(len(index)),
"downsample_step": int(step)
}
return filter_preview_response(df, b_vals, a_vals, col_idx)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=400, detail=f"CSV處理失敗: {str(e)}")
@app.post("/api/filter/download")
async def filter_csv_download(
file: UploadFile = File(...),
b: str = Form(...),
a: str = Form(...),
col_idx: int = Form(0)
col_idx: int = Form(0),
):
# 此端點專為產生包含輸出結果的 CSV 檔供下載
try:
b_vals = parse_coefficients(b, "b")
a_vals = parse_coefficients(a, "a")
df = await read_csv_upload(file)
col_to_filter, x_signal, y_signal = filtered_csv_data(df, b_vals, a_vals, col_idx)
output_col_name = f"{col_to_filter}_filtered"
df[output_col_name] = y_signal
csv_buffer = io.StringIO()
df.to_csv(csv_buffer, index=False)
csv_text = filtered_csv_text(df, b_vals, a_vals, col_idx)
return StreamingResponse(
iter([csv_buffer.getvalue()]),
iter([csv_text]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=filtered_output.csv"}
headers={"Content-Disposition": "attachment; filename=filtered_output.csv"},
)
except HTTPException:
raise
+95 -95
View File
@@ -12,7 +12,7 @@
<!-- 模式切換按鈕 -->
<button @click="toggleDarkMode"
class="p-2 rounded-lg bg-slate-100 dark:bg-gray-800 hover:bg-slate-200 dark:hover:bg-gray-700 transition-colors shadow-sm">
<svg v-if="isDarkMode" class="w-5 h-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<svg v-if="isDarkMode" class="w-5 h-5 role-theme-icon" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-13a1 1 0 10-2 0 1 1 0 002 0zM2 10a1 1 0 011-1h1a1 1 0 110 2H3a1 1 0 01-1-1zm14 0a1 1 0 011-1h1a1 1 0 110 2h-1a1 1 0 01-1-1zM5.05 5.05a1 1 0 011.414 0l.707.707a1 1 0 11-1.414 1.414l-.707-.707a1 1 0 010-1.414zm9.9 9.9a1 1 0 011.414 0l.707.707a1 1 0 11-1.414 1.414l-.707-.707a1 1 0 010-1.414zM5.05 14.95a1 1 0 010-1.414l.707-.707a1 1 0 011.414 1.414l-.707.707a1 1 0 01-1.414 0zm9.9-9.9a1 1 0 010-1.414l.707-.707a1 1 0 011.414 1.414l-.707.707a1 1 0 01-1.414 0z"
@@ -28,7 +28,7 @@
<div
class="lg:hidden flex border-b border-slate-200 dark:border-gray-800 bg-white dark:bg-dark z-10 flex-shrink-0">
<button @click="mobileTab = 'settings'" :class="mobileTab === 'settings'
? 'border-b-2 border-blue-500 text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20'
? 'border-b-2 role-border-primary role-text-primary role-bg-primary-soft'
: 'text-slate-500 dark:text-gray-400 hover:text-slate-700 dark:hover:text-gray-200'"
class="flex-1 flex items-center justify-center gap-2 py-3 text-sm font-semibold transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -41,7 +41,7 @@
設定
</button>
<button @click="mobileTab = 'chart'" :class="mobileTab === 'chart'
? 'border-b-2 border-emerald-500 text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20'
? 'border-b-2 role-border-primary role-text-primary role-bg-primary-soft'
: 'text-slate-500 dark:text-gray-400 hover:text-slate-700 dark:hover:text-gray-200'"
class="flex-1 flex items-center justify-center gap-2 py-3 text-sm font-semibold transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -50,7 +50,7 @@
</path>
</svg>
圖表
<span v-if="loadingBode" class="w-2 h-2 rounded-full bg-blue-500 animate-ping"></span>
<span v-if="loadingBode" class="w-2 h-2 rounded-full role-dot-primary animate-ping"></span>
</button>
</div>
<div class="flex flex-col lg:flex-row flex-1 overflow-hidden">
@@ -73,10 +73,10 @@
濾波器設計
</div>
<button @click="resetFilterParams"
class="text-[10px] font-bold bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 hover:bg-amber-200 dark:hover:bg-amber-800/50 px-2 py-1 rounded transition-colors uppercase">重設設計</button>
class="text-[10px] font-bold role-bg-warning-soft role-text-warning role-hover-warning-soft px-2 py-1 rounded transition-colors uppercase">重設設計</button>
</h2>
<select v-model="filterType" @change="onFilterTypeChange"
class="w-full bg-slate-50 dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none transition-all mb-3 text-slate-900 dark:text-gray-100 truncate">
class="w-full bg-slate-50 dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base role-focus-primary focus:ring-1 role-focus-ring-primary outline-none transition-all mb-3 text-slate-900 dark:text-gray-100 truncate">
<option v-for="opt in filterOptions" :key="opt" :value="opt">{{ opt }}</option>
</select>
@@ -86,16 +86,16 @@
<!-- Lowpass / Highpass -->
<div v-if="['Lowpass (低通)', 'Highpass (高通)'].includes(filterType)">
<label class="text-sm text-slate-500 dark:text-gray-400 flex justify-between mb-1"><span>截止頻率
f_c (Hz)</span><span class="text-emerald-600 dark:text-emerald-400">{{
f_c (Hz)</span><span class="role-text-primary">{{
formatK(params.fc) }}</span></label>
<input type="range" v-model.number="params.fc" @input="debouncedApply" :min="1" :max="fs/2"
class="w-full accent-blue-500 mb-2">
class="w-full role-range-primary mb-2">
<input type="number" v-model.number="params.fc" @change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base focus:border-blue-500 outline-none text-slate-900 dark:text-gray-100">
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base role-focus-primary outline-none text-slate-900 dark:text-gray-100">
<label class="text-sm text-slate-500 dark:text-gray-400 mt-3 mb-2 block">階數 Order</label>
<div class="flex gap-2">
<button v-for="o in [1,2,3]" :key="o" @click="params.order = o; applyFilterDesign()"
:class="params.order === o ? 'bg-blue-600 text-white border-blue-500' : 'bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
:class="params.order === o ? 'role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
class="flex-1 rounded py-2.5 text-base font-medium border transition-colors">{{ o
}}</button>
</div>
@@ -103,19 +103,19 @@
<!-- Bandpass -->
<div v-if="filterType === 'Bandpass (帶通)'">
<label class="text-sm text-slate-500 dark:text-gray-400 flex justify-between mb-1"><span>下截止頻率
f_low (Hz)</span><span class="text-emerald-600 dark:text-emerald-400">{{
f_low (Hz)</span><span class="role-text-primary">{{
formatK(params.bp_f_low) }}</span></label>
<input type="number" v-model.number="params.bp_f_low" @change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base mb-2 text-slate-900 dark:text-gray-100">
<label class="text-sm text-slate-500 dark:text-gray-400 flex justify-between mb-1"><span>上截止頻率
f_high (Hz)</span><span class="text-emerald-600 dark:text-emerald-400">{{
f_high (Hz)</span><span class="role-text-primary">{{
formatK(params.bp_f_high) }}</span></label>
<input type="number" v-model.number="params.bp_f_high" @change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base mb-2 text-slate-900 dark:text-gray-100">
<label class="text-sm text-slate-500 dark:text-gray-400 mt-2 mb-2 block">階數 Order</label>
<div class="flex gap-2">
<button v-for="o in [1,2,3]" :key="o" @click="params.bp_order = o; applyFilterDesign()"
:class="params.bp_order === o ? 'bg-blue-600 text-white border-blue-500' : 'bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
:class="params.bp_order === o ? 'role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
class="flex-1 rounded py-1.5 text-sm border transition-colors">{{ o }}</button>
</div>
</div>
@@ -195,7 +195,7 @@
}}</label>
<input type="number" v-model.number="params[k.key]" @change="applyFilterDesign"
step="0.0001"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base focus:border-blue-500 outline-none text-slate-900 dark:text-gray-100">
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base role-focus-primary outline-none text-slate-900 dark:text-gray-100">
</div>
</div>
<!-- SOGI -->
@@ -214,7 +214,7 @@
</div>
<!-- 提示資訊 -->
<div
class="bg-blue-100 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800/50 rounded-lg p-3 text-sm text-blue-700 dark:text-blue-300 mt-2">
class="role-bg-primary-soft border role-border-primary-soft rounded-lg p-3 text-sm role-text-primary mt-2">
調整上方參數會自動覆寫 a, b 係數你也可以隨時手動修改下方係數進行微調
</div>
</div>
@@ -237,18 +237,18 @@
<div>
<label class="text-sm text-slate-500 dark:text-gray-400 flex justify-between mb-1"><span>取樣頻率
fs
(Hz)</span><span class="text-emerald-600 dark:text-emerald-400">{{ formatK(fs)
(Hz)</span><span class="role-text-primary">{{ formatK(fs)
}}</span></label>
<input type="number" v-model.number="fs" @input="debouncedApply" @keydown="onlyNumberKey"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base focus:border-blue-500 outline-none transition-all text-slate-900 dark:text-gray-100">
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base role-focus-primary outline-none transition-all text-slate-900 dark:text-gray-100">
</div>
<div>
<label class="text-sm text-slate-500 dark:text-gray-400 flex justify-between mb-1"><span>系統增益
K (System Gain)</span><span class="text-blue-600 dark:text-blue-400">{{ systemGain
K (System Gain)</span><span class="role-text-primary">{{ systemGain
}}x</span></label>
<input type="number" v-model.number="systemGain" @input="onGainChange" step="0.1"
@keydown="onlyNumberKey"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base focus:border-blue-500 outline-none transition-all text-slate-900 dark:text-gray-100">
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base role-focus-primary outline-none transition-all text-slate-900 dark:text-gray-100">
</div>
</div>
</section>
@@ -275,15 +275,15 @@
<!-- b 係數標記顯示 -->
<div class="flex flex-wrap gap-1 mb-2">
<div v-for="item in floatingCoeffs.b" :key="item.label"
class="bg-blue-50 dark:bg-blue-900/30 px-1.5 py-0.5 rounded border border-blue-100 dark:border-blue-800/20 flex gap-1 items-center">
<span class="text-[9px] text-blue-500 font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono text-blue-800 dark:text-blue-300">{{ item.val
class="role-bg-primary-soft px-1.5 py-0.5 rounded border role-border-primary-soft flex gap-1 items-center">
<span class="text-[9px] role-text-primary-muted font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono role-text-primary">{{ item.val
}}</span>
</div>
</div>
<textarea v-model="b_str" @input="debouncedUpdateFromText('b')" @keydown="onlyNumberKey"
rows="2"
class="w-full bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-sm focus:border-blue-500 outline-none font-mono resize-none leading-relaxed text-blue-700 dark:text-blue-300"></textarea>
class="w-full bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-sm role-focus-primary outline-none font-mono resize-none leading-relaxed role-text-primary"></textarea>
</div>
<!-- b 係數倍率微調 -->
<details :open="activeCoeffAdjustment === 'bScale'"
@@ -295,7 +295,7 @@
<div class="flex items-center gap-2 mb-2 mt-1">
<span class="text-xs text-slate-400 dark:text-gray-500">靈敏度:</span>
<button v-for="s in ['1%','2x','10x']" :key="s" @click="sense_b = s"
:class="sense_b === s ? 'bg-blue-600 text-white' : 'bg-slate-200 dark:bg-gray-800 text-slate-500 dark:text-gray-400'"
:class="sense_b === s ? 'role-bg-primary role-text-on-fill' : 'bg-slate-200 dark:bg-gray-800 text-slate-500 dark:text-gray-400'"
class="btn-small px-3 py-1.5 rounded text-xs font-medium border border-slate-300 dark:border-gray-700">{{
s }}</button>
</div>
@@ -304,11 +304,11 @@
<label class="text-xs text-slate-400 dark:text-gray-500 flex justify-between mt-1">
<span>b{{ i-1 }} (基準: {{ (baseB[i-1] * (systemGain || 1.0)).toPrecision(6)
}})</span>
<span v-if="outOfRangeB[i-1]" class="text-amber-500 font-bold animate-pulse">超出滑桿範圍</span>
<span v-if="outOfRangeB[i-1]" class="role-text-warning font-bold animate-pulse">超出滑桿範圍</span>
</label>
<input type="range" v-model.number="bSliders[i-1]" @input="updateFromControls"
:min="-maxLogB" :max="maxLogB" :step="maxLogB/100"
class="w-full accent-blue-500">
class="w-full role-range-primary">
</div>
</template>
</div>
@@ -319,15 +319,15 @@
<!-- a 係數標記顯示 -->
<div class="flex flex-wrap gap-1 mb-2">
<div v-for="item in floatingCoeffs.a" :key="item.label"
class="bg-orange-50 dark:bg-orange-900/30 px-1.5 py-0.5 rounded border border-orange-100 dark:border-orange-800/20 flex gap-1 items-center">
<span class="text-[9px] text-orange-500 font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono text-orange-800 dark:text-orange-300">{{ item.val
class="role-bg-secondary-soft px-1.5 py-0.5 rounded border role-border-secondary-soft flex gap-1 items-center">
<span class="text-[9px] role-text-secondary-muted font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono role-text-secondary">{{ item.val
}}</span>
</div>
</div>
<textarea v-model="a_str" @input="debouncedUpdateFromText('a')" @keydown="onlyNumberKey"
rows="2"
class="w-full bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-sm focus:border-blue-500 outline-none font-mono resize-none leading-relaxed text-orange-700 dark:text-orange-300"></textarea>
class="w-full bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-sm role-focus-primary outline-none font-mono resize-none leading-relaxed role-text-secondary"></textarea>
</div>
<!-- a 係數倍率微調 -->
<details :open="activeCoeffAdjustment === 'aScale'"
@@ -339,7 +339,7 @@
<div class="flex items-center gap-2 mb-2 mt-1">
<span class="text-xs text-slate-400 dark:text-gray-500">靈敏度:</span>
<button v-for="s in ['1%','2x','10x']" :key="s" @click="sense_a = s"
:class="sense_a === s ? 'bg-blue-600 text-white' : 'bg-slate-200 dark:bg-gray-800 text-slate-500 dark:text-gray-400'"
:class="sense_a === s ? 'role-bg-primary role-text-on-fill' : 'bg-slate-200 dark:bg-gray-800 text-slate-500 dark:text-gray-400'"
class="px-3 py-1.5 rounded text-xs font-medium border border-slate-300 dark:border-gray-700">{{
s }}</button>
</div>
@@ -347,11 +347,11 @@
<div v-if="baseA[i] !== 0">
<label class="text-xs text-slate-400 dark:text-gray-500 flex justify-between mt-1">
<span>a{{ i }} (基準: {{ baseA[i].toPrecision(6) }})</span>
<span v-if="outOfRangeA[i]" class="text-amber-500 font-bold animate-pulse">超出滑桿範圍</span>
<span v-if="outOfRangeA[i]" class="role-text-warning font-bold animate-pulse">超出滑桿範圍</span>
</label>
<input type="range" v-model.number="aSliders[i]" @input="updateFromControls"
:min="-maxLogA" :max="maxLogA" :step="maxLogA/100"
class="w-full accent-orange-500">
class="w-full role-range-secondary">
</div>
</template>
</div>
@@ -371,9 +371,9 @@
class="rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-600 dark:text-gray-300 py-2 text-xs font-bold transition-colors hover:bg-slate-100 dark:hover:bg-gray-700">
</button>
<div class="min-w-0 rounded-lg border border-indigo-200 dark:border-indigo-900/50 bg-indigo-50 dark:bg-indigo-900/20 px-3 py-2 text-center">
<span class="block text-[9px] text-indigo-500 dark:text-indigo-300 font-bold">微調量</span>
<span class="block truncate font-mono text-sm font-bold text-indigo-700 dark:text-indigo-200" :title="formatAFineDelta()">
<div class="min-w-0 rounded-lg border role-border-tertiary-soft role-bg-tertiary-soft px-3 py-2 text-center">
<span class="block text-[9px] role-text-tertiary-muted role-text-tertiary font-bold">微調量</span>
<span class="block truncate font-mono text-sm font-bold role-text-tertiary" :title="formatAFineDelta()">
+/-{{ formatAFineDelta() }}
</span>
</div>
@@ -394,33 +394,33 @@
<input type="number" :value="formatFineCoeff(currentDelta)" :step="aFineStep"
@change="setAFineDirect('delta', $event.target.value)" @keydown="onlyNumberKey"
@wheel="handleAFineWheel('delta', $event)"
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-xs font-mono text-center text-indigo-700 dark:text-indigo-300">
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-xs font-mono text-center role-text-tertiary">
<button type="button" @pointerdown.prevent="startA1A2Repeat('delta', 1)" @pointerup="stopA1A2Repeat" @pointerleave="stopA1A2Repeat" @pointercancel="stopA1A2Repeat"
class="rounded-lg bg-slate-200 dark:bg-gray-800 hover:bg-slate-300 dark:hover:bg-gray-700 text-slate-700 dark:text-gray-300 text-sm font-bold transition-colors">+</button>
</div>
</label>
<button v-else type="button" @pointerdown.prevent="startAFineDrag('delta', $event)"
class="touch-none select-none w-28 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center active:border-indigo-400 active:ring-2 active:ring-indigo-400/20">
class="touch-none select-none w-28 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-active-border-tertiary active:ring-2 role-active-ring-tertiary">
<span class="text-[9px] text-slate-400 block font-bold mb-1">δ</span>
<span class="block max-w-full truncate text-base font-mono font-bold text-indigo-700 dark:text-indigo-300" :title="formatFineCoeff(currentDelta)">{{ formatFineCoeff(currentDelta) }}</span>
<span class="block max-w-full truncate text-base font-mono font-bold role-text-tertiary" :title="formatFineCoeff(currentDelta)">{{ formatFineCoeff(currentDelta) }}</span>
</button>
<label v-if="!isTouchInput" class="block">
<span class="text-[9px] text-slate-400 block font-bold mb-1">r</span>
<div class="grid grid-cols-[2rem_1fr_2rem] gap-1">
<button type="button" @pointerdown.prevent="startA1A2Repeat('r', -1)" @pointerup="stopA1A2Repeat" @pointerleave="stopA1A2Repeat" @pointercancel="stopA1A2Repeat"
class="rounded-lg bg-indigo-100 dark:bg-indigo-900/30 hover:bg-indigo-200 dark:hover:bg-indigo-900/50 text-indigo-700 dark:text-indigo-300 text-sm font-bold transition-colors">-</button>
class="rounded-lg role-bg-tertiary-soft role-hover-tertiary-soft role-text-tertiary text-sm font-bold transition-colors">-</button>
<input type="number" :value="formatFineCoeff(currentR)" :step="aFineStep"
@change="setAFineDirect('r', $event.target.value)" @keydown="onlyNumberKey"
@wheel="handleAFineWheel('r', $event)"
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-xs font-mono text-center text-emerald-700 dark:text-emerald-300">
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-xs font-mono text-center role-text-primary">
<button type="button" @pointerdown.prevent="startA1A2Repeat('r', 1)" @pointerup="stopA1A2Repeat" @pointerleave="stopA1A2Repeat" @pointercancel="stopA1A2Repeat"
class="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 hover:bg-emerald-200 dark:hover:bg-emerald-900/50 text-emerald-700 dark:text-emerald-300 text-sm font-bold transition-colors">+</button>
class="rounded-lg role-bg-primary-soft role-hover-primary-soft role-text-primary text-sm font-bold transition-colors">+</button>
</div>
</label>
<button v-else type="button" @pointerdown.prevent="startAFineDrag('r', $event)"
class="touch-none select-none w-28 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center active:border-emerald-400 active:ring-2 active:ring-emerald-400/20">
class="touch-none select-none w-28 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-active-border-primary active:ring-2 role-active-ring-primary">
<span class="text-[9px] text-slate-400 block font-bold mb-1">r</span>
<span class="block max-w-full truncate text-base font-mono font-bold text-emerald-700 dark:text-emerald-300" :title="formatFineCoeff(currentR)">{{ formatFineCoeff(currentR) }}</span>
<span class="block max-w-full truncate text-base font-mono font-bold role-text-primary" :title="formatFineCoeff(currentR)">{{ formatFineCoeff(currentR) }}</span>
</button>
</div>
</div>
@@ -428,11 +428,11 @@
<div class="grid grid-cols-2 gap-2 mb-4">
<div class="min-w-0 bg-white dark:bg-gray-800 border border-slate-100 dark:border-gray-700 rounded-lg p-2 text-center shadow-sm">
<span class="text-[9px] text-slate-400 block font-bold mb-1">a1 = -1 - r + δ</span>
<span class="block max-w-full truncate text-sm font-mono font-bold text-indigo-600 dark:text-indigo-400" :title="formatFineCoeff(currentA1)">{{ formatFineCoeff(currentA1) }}</span>
<span class="block max-w-full truncate text-sm font-mono font-bold role-text-tertiary" :title="formatFineCoeff(currentA1)">{{ formatFineCoeff(currentA1) }}</span>
</div>
<div class="min-w-0 bg-white dark:bg-gray-800 border border-slate-100 dark:border-gray-700 rounded-lg p-2 text-center shadow-sm">
<span class="text-[9px] text-slate-400 block font-bold mb-1">a2 = r + δ</span>
<span class="block max-w-full truncate text-sm font-mono font-bold text-emerald-600 dark:text-emerald-400" :title="formatFineCoeff(currentA2)">{{ formatFineCoeff(currentA2) }}</span>
<span class="block max-w-full truncate text-sm font-mono font-bold role-text-primary" :title="formatFineCoeff(currentA2)">{{ formatFineCoeff(currentA2) }}</span>
</div>
<div class="min-w-0 bg-white dark:bg-gray-800 border border-slate-100 dark:border-gray-700 rounded-lg p-2 text-center shadow-sm">
<span class="text-[9px] text-slate-400 block font-bold mb-1">差值 a1-a2</span>
@@ -444,8 +444,8 @@
</div>
</div>
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-100 dark:border-amber-800/30 rounded-lg p-3">
<p class="text-[10px] text-amber-700 dark:text-amber-400 leading-relaxed">
<div class="role-bg-warning-soft border role-border-warning-soft rounded-lg p-3">
<p class="text-[10px] role-text-warning leading-relaxed">
<span class="font-bold">運算說明</span><br>
δ = (a1 + a2 + 1) / 2r = (a2 - a1 - 1) / 2更改數值會自動更新圖表桌機可用 +/- shift +
</p>
@@ -467,10 +467,10 @@
</svg>
<span>定點數係數轉換</span>
<span
class="text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 px-2 py-1 rounded">b Q{{
class="text-xs role-bg-primary-soft role-text-primary px-2 py-1 rounded">b Q{{
shiftBitsB }}</span>
<span
class="text-xs bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 px-2 py-1 rounded">a Q{{
class="text-xs role-bg-secondary-soft role-text-secondary px-2 py-1 rounded">a Q{{
shiftBitsA }}</span>
</div>
<div class="flex items-center gap-2">
@@ -484,17 +484,17 @@
<label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">b 左移位元數 (Shift Bits)</label>
<div v-if="!isTouchInput" class="grid grid-cols-[2rem_1fr_2rem] gap-1">
<button type="button" @pointerdown.prevent="startShiftBitRepeat('b', -1)" @pointerup="stopShiftBitRepeat" @pointerleave="stopShiftBitRepeat" @pointercancel="stopShiftBitRepeat"
class="rounded-lg bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-200 dark:hover:bg-blue-900/50 text-blue-700 dark:text-blue-300 text-sm font-bold transition-colors">-</button>
class="rounded-lg role-bg-primary-soft role-hover-primary-soft role-text-primary text-sm font-bold transition-colors">-</button>
<input type="number" :value="shiftBitsB" min="0"
@change="setShiftBits('b', $event.target.value)" @keydown="onlyNumberKey"
@wheel="handleShiftBitWheel('b', $event)"
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base focus:border-blue-500 outline-none transition-all text-slate-900 dark:text-gray-100">
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base role-focus-primary outline-none transition-all text-slate-900 dark:text-gray-100">
<button type="button" @pointerdown.prevent="startShiftBitRepeat('b', 1)" @pointerup="stopShiftBitRepeat" @pointerleave="stopShiftBitRepeat" @pointercancel="stopShiftBitRepeat"
class="rounded-lg bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-200 dark:hover:bg-blue-900/50 text-blue-700 dark:text-blue-300 text-sm font-bold transition-colors">+</button>
class="rounded-lg role-bg-primary-soft role-hover-primary-soft role-text-primary text-sm font-bold transition-colors">+</button>
</div>
<button v-else type="button" @pointerdown.prevent="startShiftBitDrag('b', $event)"
class="touch-none select-none block w-24 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center active:border-blue-400 active:ring-2 active:ring-blue-400/20">
<span class="text-lg font-mono font-bold text-blue-700 dark:text-blue-300">{{ shiftBitsB }}</span>
class="touch-none select-none block w-24 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-active-border-primary active:ring-2 role-active-ring-primary">
<span class="text-lg font-mono font-bold role-text-primary">{{ shiftBitsB }}</span>
</button>
<p class="text-[10px] text-slate-400 mt-1">b scaling: 2^{{ shiftBitsB }} ({{
Math.pow(2, shiftBitsB) }})</p>
@@ -503,17 +503,17 @@
<label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">a 左移位元數 (Shift Bits)</label>
<div v-if="!isTouchInput" class="grid grid-cols-[2rem_1fr_2rem] gap-1">
<button type="button" @pointerdown.prevent="startShiftBitRepeat('a', -1)" @pointerup="stopShiftBitRepeat" @pointerleave="stopShiftBitRepeat" @pointercancel="stopShiftBitRepeat"
class="rounded-lg bg-orange-100 dark:bg-orange-900/30 hover:bg-orange-200 dark:hover:bg-orange-900/50 text-orange-700 dark:text-orange-300 text-sm font-bold transition-colors">-</button>
class="rounded-lg role-bg-secondary-soft role-hover-secondary-soft role-text-secondary text-sm font-bold transition-colors">-</button>
<input type="number" :value="shiftBitsA" min="0"
@change="setShiftBits('a', $event.target.value)" @keydown="onlyNumberKey"
@wheel="handleShiftBitWheel('a', $event)"
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base focus:border-blue-500 outline-none transition-all text-slate-900 dark:text-gray-100">
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base role-focus-primary outline-none transition-all text-slate-900 dark:text-gray-100">
<button type="button" @pointerdown.prevent="startShiftBitRepeat('a', 1)" @pointerup="stopShiftBitRepeat" @pointerleave="stopShiftBitRepeat" @pointercancel="stopShiftBitRepeat"
class="rounded-lg bg-orange-100 dark:bg-orange-900/30 hover:bg-orange-200 dark:hover:bg-orange-900/50 text-orange-700 dark:text-orange-300 text-sm font-bold transition-colors">+</button>
class="rounded-lg role-bg-secondary-soft role-hover-secondary-soft role-text-secondary text-sm font-bold transition-colors">+</button>
</div>
<button v-else type="button" @pointerdown.prevent="startShiftBitDrag('a', $event)"
class="touch-none select-none block w-24 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center active:border-orange-400 active:ring-2 active:ring-orange-400/20">
<span class="text-lg font-mono font-bold text-orange-700 dark:text-orange-300">{{ shiftBitsA }}</span>
class="touch-none select-none block w-24 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-active-border-secondary active:ring-2 role-active-ring-secondary">
<span class="text-lg font-mono font-bold role-text-secondary">{{ shiftBitsA }}</span>
</button>
<p class="text-[10px] text-slate-400 mt-1">a scaling: 2^{{ shiftBitsA }} ({{
Math.pow(2, shiftBitsA) }})</p>
@@ -526,30 +526,30 @@
(Integers)</label>
<div class="flex flex-wrap gap-1 mb-2">
<div v-for="(item, idx) in fixedPointCoeffs.b" :key="item.label"
:class="item.isOverridden ? 'border-blue-400 ring-1 ring-blue-400/20' : 'border-blue-100 dark:border-blue-800/30'"
class="bg-blue-50 dark:bg-blue-900/20 px-1.5 py-0.5 rounded border flex items-center gap-1 transition-all">
<span class="text-[9px] text-blue-400 font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono text-blue-700 dark:text-blue-300 font-bold"
:class="item.isOverridden ? 'role-border-primary ring-1 role-ring-primary' : 'role-border-primary-soft'"
class="role-bg-primary-soft px-1.5 py-0.5 rounded border flex items-center gap-1 transition-all">
<span class="text-[9px] role-text-primary font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono role-text-primary font-bold"
:class="{ 'italic': !item.isOverridden }">{{ item.val }}</span>
</div>
</div>
<textarea v-model="b_int_str" @keydown="onlyNumberKey($event, false)" rows="2"
class="w-full bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-sm focus:border-blue-500 outline-none font-mono resize-none leading-relaxed text-blue-700 dark:text-blue-300"></textarea>
class="w-full bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-sm role-focus-primary outline-none font-mono resize-none leading-relaxed role-text-primary"></textarea>
</div>
<div>
<label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">定點分母係數 a
(Integers)</label>
<div class="flex flex-wrap gap-1 mb-2">
<div v-for="(item, idx) in fixedPointCoeffs.a" :key="item.label"
:class="item.isOverridden ? 'border-orange-400 ring-1 ring-orange-400/20' : 'border-orange-100 dark:border-orange-800/30'"
class="bg-orange-50 dark:bg-orange-900/20 px-1.5 py-0.5 rounded border flex items-center gap-1 transition-all">
<span class="text-[9px] text-orange-400 font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono text-orange-700 dark:text-orange-300 font-bold"
:class="item.isOverridden ? 'role-border-secondary ring-1 role-ring-secondary' : 'role-border-secondary-soft'"
class="role-bg-secondary-soft px-1.5 py-0.5 rounded border flex items-center gap-1 transition-all">
<span class="text-[9px] role-text-secondary-muted font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono role-text-secondary font-bold"
:class="{ 'italic': !item.isOverridden }">{{ item.val }}</span>
</div>
</div>
<textarea v-model="a_int_str" @keydown="onlyNumberKey($event, false)" rows="2"
class="w-full bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-sm focus:border-blue-500 outline-none font-mono resize-none leading-relaxed text-orange-700 dark:text-orange-300"></textarea>
class="w-full bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-sm role-focus-primary outline-none font-mono resize-none leading-relaxed role-text-secondary"></textarea>
<details class="mt-3 bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-800 rounded-lg">
<summary class="text-sm text-slate-500 dark:text-gray-400 px-3 py-3 cursor-pointer hover:text-slate-600 dark:hover:text-gray-300">
a1/a2 步階微調</summary>
@@ -558,9 +558,9 @@
<div class="grid grid-cols-[3.25rem_1fr_3.25rem] gap-2 mb-3">
<button type="button" @click="adjustFixedAFineStep(-1)" title="調粗"
class="rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-600 dark:text-gray-300 py-2 text-xs font-bold transition-colors hover:bg-slate-100 dark:hover:bg-gray-700"></button>
<div class="min-w-0 rounded-lg border border-orange-200 dark:border-orange-900/50 bg-orange-50 dark:bg-orange-900/20 px-3 py-2 text-center">
<span class="block text-[9px] text-orange-500 dark:text-orange-300 font-bold">微調量</span>
<span class="block truncate font-mono text-sm font-bold text-orange-700 dark:text-orange-200" :title="formatFixedAFineDelta()">+/-{{ formatFixedAFineDelta() }}</span>
<div class="min-w-0 rounded-lg border role-border-secondary-soft role-bg-secondary-soft px-3 py-2 text-center">
<span class="block text-[9px] role-text-secondary-muted role-text-secondary font-bold">微調量</span>
<span class="block truncate font-mono text-sm font-bold role-text-secondary" :title="formatFixedAFineDelta()">+/-{{ formatFixedAFineDelta() }}</span>
</div>
<button type="button" @click="adjustFixedAFineStep(1)" title="調細"
class="rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-600 dark:text-gray-300 py-2 text-xs font-bold transition-colors hover:bg-slate-100 dark:hover:bg-gray-700"></button>
@@ -573,19 +573,19 @@
<span class="text-[9px] text-slate-400 block font-bold mb-1">δ</span>
<div class="grid grid-cols-[2rem_1fr_2rem] gap-1">
<button type="button" @pointerdown.prevent="startFixedA1A2Repeat('delta', -1)" @pointerup="stopFixedA1A2Repeat" @pointerleave="stopFixedA1A2Repeat" @pointercancel="stopFixedA1A2Repeat"
class="rounded-lg bg-orange-100 dark:bg-orange-900/30 hover:bg-orange-200 dark:hover:bg-orange-900/50 text-orange-700 dark:text-orange-300 text-sm font-bold transition-colors">-</button>
class="rounded-lg role-bg-secondary-soft role-hover-secondary-soft role-text-secondary text-sm font-bold transition-colors">-</button>
<input type="number" :value="formatFixedFineCoeff(fixedCurrentDelta)" :step="fixedAFineStep"
@change="setFixedAFineDirect('delta', $event.target.value)" @keydown="onlyNumberKey"
@wheel="handleFixedAFineWheel('delta', $event)"
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-xs font-mono text-center text-orange-700 dark:text-orange-300">
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-xs font-mono text-center role-text-secondary">
<button type="button" @pointerdown.prevent="startFixedA1A2Repeat('delta', 1)" @pointerup="stopFixedA1A2Repeat" @pointerleave="stopFixedA1A2Repeat" @pointercancel="stopFixedA1A2Repeat"
class="rounded-lg bg-orange-100 dark:bg-orange-900/30 hover:bg-orange-200 dark:hover:bg-orange-900/50 text-orange-700 dark:text-orange-300 text-sm font-bold transition-colors">+</button>
class="rounded-lg role-bg-secondary-soft role-hover-secondary-soft role-text-secondary text-sm font-bold transition-colors">+</button>
</div>
</label>
<button v-else type="button" @pointerdown.prevent="startFixedAFineDrag('delta', $event)"
class="touch-none select-none w-28 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center active:border-orange-400 active:ring-2 active:ring-orange-400/20">
class="touch-none select-none w-28 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-active-border-secondary active:ring-2 role-active-ring-secondary">
<span class="text-[9px] text-slate-400 block font-bold mb-1">δ</span>
<span class="block max-w-full truncate text-base font-mono font-bold text-orange-700 dark:text-orange-300" :title="formatFixedFineCoeff(fixedCurrentDelta)">{{ formatFixedFineCoeff(fixedCurrentDelta) }}</span>
<span class="block max-w-full truncate text-base font-mono font-bold role-text-secondary" :title="formatFixedFineCoeff(fixedCurrentDelta)">{{ formatFixedFineCoeff(fixedCurrentDelta) }}</span>
</button>
<label v-if="!isTouchInput" class="block">
<span class="text-[9px] text-slate-400 block font-bold mb-1">r</span>
@@ -610,11 +610,11 @@
<div class="grid grid-cols-2 gap-2 mb-4">
<div class="min-w-0 bg-white dark:bg-gray-800 border border-slate-100 dark:border-gray-700 rounded-lg p-2 text-center shadow-sm">
<span class="text-[9px] text-slate-400 block font-bold mb-1">a1 = -a0 - r + δ</span>
<span class="block max-w-full truncate text-sm font-mono font-bold text-orange-600 dark:text-orange-400" :title="formatFixedFineCoeff(fixedCurrentA1)">{{ formatFixedFineCoeff(fixedCurrentA1) }}</span>
<span class="block max-w-full truncate text-sm font-mono font-bold role-text-secondary-muted" :title="formatFixedFineCoeff(fixedCurrentA1)">{{ formatFixedFineCoeff(fixedCurrentA1) }}</span>
</div>
<div class="min-w-0 bg-white dark:bg-gray-800 border border-slate-100 dark:border-gray-700 rounded-lg p-2 text-center shadow-sm">
<span class="text-[9px] text-slate-400 block font-bold mb-1">a2 = r + δ</span>
<span class="block max-w-full truncate text-sm font-mono font-bold text-orange-600 dark:text-orange-400" :title="formatFixedFineCoeff(fixedCurrentA2)">{{ formatFixedFineCoeff(fixedCurrentA2) }}</span>
<span class="block max-w-full truncate text-sm font-mono font-bold role-text-secondary-muted" :title="formatFixedFineCoeff(fixedCurrentA2)">{{ formatFixedFineCoeff(fixedCurrentA2) }}</span>
</div>
<div class="min-w-0 bg-white dark:bg-gray-800 border border-slate-100 dark:border-gray-700 rounded-lg p-2 text-center shadow-sm">
<span class="text-[9px] text-slate-400 block font-bold mb-1">差值 a1-a2</span>
@@ -625,8 +625,8 @@
<span class="block max-w-full truncate text-xs font-mono text-slate-600 dark:text-gray-300" :title="formatFixedFineCoeff(fixedCurrentA1A2Sum)">{{ formatFixedFineCoeff(fixedCurrentA1A2Sum) }}</span>
</div>
</div>
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-100 dark:border-amber-800/30 rounded-lg p-3">
<p class="text-[10px] text-amber-700 dark:text-amber-400 leading-relaxed">
<div class="role-bg-warning-soft border role-border-warning-soft rounded-lg p-3">
<p class="text-[10px] role-text-warning leading-relaxed">
<span class="font-bold">運算說明</span><br>
a0 = 2^{{ shiftBitsA }}δ = (a1 + a2 + a0) / 2r = (a2 - a1 - a0) / 2
</p>
@@ -645,33 +645,33 @@
<main :class="mobileTab === 'chart' ? 'flex bg-white dark:bg-[#0f0f0f] !p-0' : 'hidden'"
class="lg:flex flex-1 flex-col overflow-y-auto bg-slate-50 dark:bg-[#0f0f0f] lg:p-6 gap-0 lg:gap-6 custom-scrollbar relative transition-colors duration-300">
<div v-if="globalError"
class="absolute top-4 right-6 bg-red-100 dark:bg-red-900 border border-red-200 dark:border-red-700 text-red-700 dark:text-white px-4 py-3 rounded-lg shadow-lg text-base z-50 flex items-center gap-2">
class="absolute top-4 right-6 role-bg-error-soft border role-border-error role-text-error role-text-on-fill px-4 py-3 rounded-lg shadow-lg text-base z-50 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
{{ globalError }}
<button @click="globalError = null"
class="ml-2 hover:text-red-900 dark:hover:text-red-200">&times;</button>
class="ml-2 role-hover-error">&times;</button>
</div>
<!-- Bode Plot -->
<div
class="bg-white dark:bg-dark lg:rounded-xl border-b lg:border border-slate-100 lg:border-slate-200 dark:border-gray-800 lg:dark:border-gray-800 lg:shadow-md p-0 lg:p-1 flex-shrink-0 relative transition-colors duration-300">
<div
class="hidden lg:block absolute inset-0 bg-gradient-to-br from-blue-500/5 to-purple-500/5 pointer-events-none">
class="hidden lg:block absolute inset-0 bg-gradient-to-br role-surface-gradient pointer-events-none">
</div>
<div
class="px-5 py-4 border-b border-slate-100 dark:border-gray-800 flex justify-between items-center bg-white dark:bg-dark backdrop-blur-sm z-10 relative">
<h3 class="font-bold text-slate-800 dark:text-gray-200 tracking-wide flex items-center gap-2">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-5 h-5 role-text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 19V5m0 14h16M7 16l3-4 3 2 4-7"></path>
</svg>
頻率響應 (Frequency Response)</h3>
<div v-if="loadingBode"
class="text-sm text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30 px-3 py-1.5 rounded border border-blue-100 dark:border-blue-800/50 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-blue-500 dark:bg-blue-400 animate-ping"></span>
class="text-sm role-text-primary role-bg-primary-soft px-3 py-1.5 rounded border role-border-primary-soft flex items-center gap-2">
<span class="w-2 h-2 rounded-full role-dot-primary animate-ping"></span>
重新計算中...
</div>
</div>
@@ -688,7 +688,7 @@
<div
class="px-5 py-4 border-b border-slate-100 dark:border-gray-800 bg-white dark:bg-dark backdrop-blur-sm z-10 relative">
<h3 class="font-bold text-slate-800 dark:text-gray-200 tracking-wide flex items-center gap-2">
<svg class="w-5 h-5 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-5 h-5 role-text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 12h4l2-6 4 12 2-6h4"></path>
</svg>
@@ -700,7 +700,7 @@
<div
class="flex items-center flex-wrap gap-4 mb-6 p-4 bg-slate-50 dark:bg-gray-900/50 rounded-lg border border-slate-100 dark:border-gray-800">
<label
class="cursor-pointer bg-blue-600 hover:bg-blue-500 text-white px-5 py-3 rounded-lg text-base font-semibold transition-all shadow-md hover:shadow-blue-500/20 flex items-center gap-2">
class="cursor-pointer role-bg-primary role-hover-primary role-text-on-fill px-5 py-3 rounded-lg text-base font-semibold transition-all shadow-md role-shadow-primary flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
@@ -709,14 +709,14 @@
<input type="file" ref="fileInput" accept=".csv" @change="handleFileUpload"
class="hidden">
</label>
<span v-if="csvFile" class="text-base text-emerald-600 dark:text-emerald-400 font-mono">{{
<span v-if="csvFile" class="text-base role-text-primary font-mono">{{
csvFile.name }}</span>
<span v-if="csvInfo"
class="text-sm text-slate-500 dark:text-gray-400 font-mono">
{{ csvInfo.rows }} / {{ csvInfo.columns }}
</span>
<span v-if="csvParseError"
class="text-sm text-red-600 dark:text-red-400 font-semibold">
class="text-sm role-text-error font-semibold">
{{ csvParseError }}
</span>
@@ -726,19 +726,19 @@
<div v-if="csvColumns.length > 0" class="flex items-center gap-2">
<span class="text-sm text-slate-400">訊號欄位:</span>
<select v-model="selectedColumn"
class="bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base focus:border-blue-500 outline-none text-slate-900 dark:text-gray-100">
class="bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base role-focus-primary outline-none text-slate-900 dark:text-gray-100">
<option v-for="(col, idx) in csvColumns" :key="idx" :value="idx">{{ col }}</option>
</select>
</div>
<button v-if="csvFile" @click="processFilter"
class="bg-emerald-600 hover:bg-emerald-500 text-white px-6 py-3 rounded-lg text-base font-semibold transition-all shadow-md hover:shadow-emerald-500/20 disabled:opacity-50 disabled:cursor-not-allowed"
class="role-bg-primary role-hover-primary role-text-on-fill px-6 py-3 rounded-lg text-base font-semibold transition-all shadow-md role-shadow-primary disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="loadingFilter || csvColumns.length === 0">
{{ loadingFilter ? '處理中...' : '執行當前濾波器' }}
</button>
<button v-if="filterDone" @click="downloadCsv"
class="ml-auto bg-slate-700 hover:bg-slate-600 text-white px-5 py-3 rounded-lg text-base font-semibold transition-all flex items-center gap-2">
class="ml-auto bg-slate-700 hover:bg-slate-600 role-text-on-fill px-5 py-3 rounded-lg text-base font-semibold transition-all flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
+24 -22
View File
@@ -846,13 +846,13 @@ export default {
shapes.push({
type: 'line', x0: xVal, x1: xVal, y0: 0, y1: 1,
xref: row === 1 ? 'x' : 'x2', yref: yref + ' domain',
line: { color: isDark ? 'rgba(242,184,181,0.72)' : 'rgba(186,26,26,0.72)', width: 1.4, dash: 'dash' }
line: { color: isDark ? 'rgba(230,184,180,0.72)' : 'rgba(162,95,91,0.72)', width: 1.4, dash: 'dash' }
});
if (text) {
annotations.push({
x: Math.log10(xVal), y: 1, text: text,
xref: row === 1 ? 'x' : 'x2', yref: yref + ' domain',
showarrow: false, font: { color: isDark ? '#f2b8b5' : '#ba1a1a', size: isSmallScreen ? 10 : 12 },
showarrow: false, font: { color: isDark ? '#e6b8b4' : '#a25f5b', size: isSmallScreen ? 10 : 12 },
yshift: isSmallScreen ? 15 : 12,
xanchor: 'center',
yanchor: isSmallScreen ? 'middle' : 'bottom',
@@ -869,7 +869,7 @@ export default {
for (let p = pStart; p <= pEnd; p++) {
const v = Math.pow(10, p);
if (v >= fMin && v <= fMax) {
const gridColor = isDark ? 'rgba(198,204,216,0.24)' : 'rgba(63,95,159,0.14)';
const gridColor = isDark ? 'rgba(199,206,193,0.24)' : 'rgba(101,120,104,0.16)';
shapes.push(
{ type: 'line', x0: v, x1: v, y0: 0, y1: 1, xref: 'x', yref: 'y domain', line: { color: gridColor, width: 1.5 } },
{ type: 'line', x0: v, x1: v, y0: 0, y1: 1, xref: 'x2', yref: 'y2 domain', line: { color: gridColor, width: 1.5 } }
@@ -877,10 +877,11 @@ export default {
}
}
const gridColor = isDark ? 'rgba(198,204,216,0.14)' : 'rgba(63,95,159,0.08)';
const zeroLineColor = isDark ? 'rgba(198,204,216,0.24)' : 'rgba(70,75,85,0.18)';
const textColor = isDark ? '#c6ccd8' : '#38404c';
const titleColor = isDark ? '#e4e7ef' : '#1a1b1f';
const gridColor = isDark ? 'rgba(199,206,193,0.14)' : 'rgba(101,120,104,0.09)';
const zeroLineColor = isDark ? 'rgba(199,206,193,0.24)' : 'rgba(70,75,67,0.20)';
const textColor = isDark ? '#c7cec1' : '#42483f';
const titleColor = isDark ? '#e5e9df' : '#1b1d19';
const plotBgColor = isDark ? '#0b0e0a' : '#ffffff';
const xAxisCommon = {
type: 'log', title: { text: 'Freq (Hz)', font: { size: isSmallScreen ? 12 : 14 } },
@@ -892,7 +893,7 @@ export default {
};
const layout = {
paper_bgcolor: 'transparent', plot_bgcolor: 'transparent',
paper_bgcolor: plotBgColor, plot_bgcolor: plotBgColor,
margin: isSmallScreen ? { t: 80, b: 120, l: 55, r: 15 } : { t: 70, b: 120, l: 70, r: 30 },
showlegend: false,
grid: { rows: 2, columns: 1, pattern: 'independent', roworder: 'top to bottom', ygap: isSmallScreen ? 0.35 : 0.3 },
@@ -902,15 +903,15 @@ export default {
{ text: 'Phase Response (Deg)', xref: 'x2 domain', yref: 'y2 domain', x: 0.5, y: isSmallScreen ? 1.25 : 1.15, showarrow: false, font: { size: isSmallScreen ? 14 : 16, color: titleColor, weight: 'bold' } },
// 手動 Magnitude 圖例 (位於上方圖表下方)
{ xref: 'paper', yref: 'paper', x: 0.35, y: isSmallScreen ? 0.52 : 0.54, text: '一一一', font: { color: isDark ? '#b9c6ff' : '#3f5f9f', size: 10 }, showarrow: false },
{ xref: 'paper', yref: 'paper', x: 0.35, y: isSmallScreen ? 0.52 : 0.54, text: '一一一', font: { color: isDark ? '#b7c9b8' : '#657868', size: 10 }, showarrow: false },
{ xref: 'paper', yref: 'paper', x: 0.42, y: isSmallScreen ? 0.52 : 0.54, text: 'Ideal (Float)', font: { color: textColor, size: 11 }, showarrow: false, xanchor: 'left' },
{ xref: 'paper', yref: 'paper', x: 0.58, y: isSmallScreen ? 0.52 : 0.54, text: '· · · ·', font: { color: isDark ? '#d7da7d' : '#6d702b', size: 14 }, showarrow: false },
{ xref: 'paper', yref: 'paper', x: 0.58, y: isSmallScreen ? 0.52 : 0.54, text: '· · · ·', font: { color: isDark ? '#c6cbbf' : '#73786f', size: 14 }, showarrow: false },
{ xref: 'paper', yref: 'paper', x: 0.63, y: isSmallScreen ? 0.52 : 0.54, text: `Fixed (b Q${this.shiftBitsB}, a Q${this.shiftBitsA})`, font: { color: textColor, size: 11 }, showarrow: false, xanchor: 'left' },
// 手動 Phase 圖例 (位於下方圖表下方)
{ xref: 'paper', yref: 'paper', x: 0.35, y: -0.15, text: '一一一', font: { color: isDark ? '#c4c9d8' : '#4f6f80', size: 10 }, showarrow: false },
{ xref: 'paper', yref: 'paper', x: 0.35, y: -0.15, text: '一一一', font: { color: isDark ? '#d4c5aa' : '#877b68', size: 10 }, showarrow: false },
{ xref: 'paper', yref: 'paper', x: 0.42, y: -0.15, text: 'Ideal Phase', font: { color: textColor, size: 11 }, showarrow: false, xanchor: 'left' },
{ xref: 'paper', yref: 'paper', x: 0.58, y: -0.15, text: '· · · ·', font: { color: isDark ? '#f2b8b5' : '#ba1a1a', size: 14 }, showarrow: false },
{ xref: 'paper', yref: 'paper', x: 0.58, y: -0.15, text: '· · · ·', font: { color: isDark ? '#e6b8b4' : '#a25f5b', size: 14 }, showarrow: false },
{ xref: 'paper', yref: 'paper', x: 0.63, y: -0.15, text: 'Fixed Phase', font: { color: textColor, size: 11 }, showarrow: false, xanchor: 'left' },
...annotations
@@ -922,11 +923,11 @@ export default {
shapes: shapes
};
const traceMagIdeal = { x: freq, y: magIdeal, name: 'Ideal (Float)', type: 'scatter', line: { color: isDark ? '#b9c6ff' : '#3f5f9f', width: 2.8 } };
const traceMagFixed = { x: freq, y: magFixed, name: `Fixed (b Q${this.shiftBitsB}, a Q${this.shiftBitsA})`, type: 'scatter', line: { color: isDark ? '#d7da7d' : '#6d702b', width: 2.4, dash: 'dot' } };
const traceMagIdeal = { x: freq, y: magIdeal, name: 'Ideal (Float)', type: 'scatter', line: { color: isDark ? '#b7c9b8' : '#657868', width: 2.8 } };
const traceMagFixed = { x: freq, y: magFixed, name: `Fixed (b Q${this.shiftBitsB}, a Q${this.shiftBitsA})`, type: 'scatter', line: { color: isDark ? '#c6cbbf' : '#73786f', width: 2.4, dash: 'dot' } };
const tracePhaseIdeal = { x: freq, y: phaseIdeal, name: 'Ideal Phase', type: 'scatter', line: { color: isDark ? '#c4c9d8' : '#4f6f80', width: 2.8 }, xaxis: 'x2', yaxis: 'y2' };
const tracePhaseFixed = { x: freq, y: phaseFixed, name: 'Fixed Phase', type: 'scatter', line: { color: isDark ? '#f2b8b5' : '#ba1a1a', width: 2.4, dash: 'dot' }, xaxis: 'x2', yaxis: 'y2' };
const tracePhaseIdeal = { x: freq, y: phaseIdeal, name: 'Ideal Phase', type: 'scatter', line: { color: isDark ? '#d4c5aa' : '#877b68', width: 2.8 }, xaxis: 'x2', yaxis: 'y2' };
const tracePhaseFixed = { x: freq, y: phaseFixed, name: 'Fixed Phase', type: 'scatter', line: { color: isDark ? '#e6b8b4' : '#a25f5b', width: 2.4, dash: 'dot' }, xaxis: 'x2', yaxis: 'y2' };
Plotly.react('bodePlot', [traceMagIdeal, traceMagFixed, tracePhaseIdeal, tracePhaseFixed], layout, { responsive: true });
},
@@ -1077,12 +1078,13 @@ export default {
drawTimePlot(data) {
const isDark = this.isDarkMode;
const isSmallScreen = window.innerWidth < 768;
const gridColor = isDark ? 'rgba(198,204,216,0.14)' : 'rgba(63,95,159,0.08)';
const zeroLineColor = isDark ? 'rgba(198,204,216,0.24)' : 'rgba(70,75,85,0.18)';
const textColor = isDark ? '#c6ccd8' : '#38404c';
const gridColor = isDark ? 'rgba(199,206,193,0.14)' : 'rgba(101,120,104,0.09)';
const zeroLineColor = isDark ? 'rgba(199,206,193,0.24)' : 'rgba(70,75,67,0.20)';
const textColor = isDark ? '#c7cec1' : '#42483f';
const plotBgColor = isDark ? '#0b0e0a' : '#ffffff';
const layout = {
paper_bgcolor: 'transparent', plot_bgcolor: 'transparent',
paper_bgcolor: plotBgColor, plot_bgcolor: plotBgColor,
margin: isSmallScreen ? { t: 60, b: 60, l: 45, r: 15 } : { t: 40, b: 55, l: 60, r: 20 },
font: { color: textColor, family: 'Google Sans, Roboto, sans-serif' },
xaxis: { title: { text: 'Sample Index', font: { size: isSmallScreen ? 10 : 11 } }, gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 9 : 11 } },
@@ -1090,8 +1092,8 @@ export default {
legend: { orientation: 'h', y: 1.02, yanchor: 'bottom', x: 1, xanchor: 'right', font: { color: textColor, size: isSmallScreen ? 9 : 10 } }
};
Plotly.react('timePlot', [
{ x: data.index, y: data.original, name: '原始輸入訊號 (Input)', type: 'scatter', line: { color: isDark ? '#b9c6ff' : '#3f5f9f', width: 2.4 }, opacity: 0.9 },
{ x: data.index, y: data.filtered, name: '濾波後輸出 (Output)', type: 'scatter', line: { color: isDark ? '#d7da7d' : '#6d702b', width: 2.4 }, opacity: 0.95 }
{ x: data.index, y: data.original, name: '原始輸入訊號 (Input)', type: 'scatter', line: { color: isDark ? '#b7c9b8' : '#657868', width: 2.4 }, opacity: 0.9 },
{ x: data.index, y: data.filtered, name: '濾波後輸出 (Output)', type: 'scatter', line: { color: isDark ? '#c6cbbf' : '#73786f', width: 2.4 }, opacity: 0.95 }
], layout, { responsive: true });
},
+365 -288
View File
@@ -4,74 +4,76 @@
@tailwind components;
@tailwind utilities;
/* Material 3 Expressive readability pass.
Keep this after Tailwind so these focused overrides win without changing app logic. */
/* Morandi green hierarchy. Feature colors are expressed through role classes only. */
:root {
--m3-primary: #3f5f9f;
--m3-primary: #657868;
--m3-on-primary: #ffffff;
--m3-primary-container: #dbe4ff;
--m3-on-primary-container: #001947;
--m3-secondary: #5b6172;
--m3-primary-container: #dce7dc;
--m3-on-primary-container: #1b2a1f;
--m3-secondary: #73786f;
--m3-on-secondary: #ffffff;
--m3-secondary-container: #e0e5f2;
--m3-on-secondary-container: #181c26;
--m3-tertiary: #6d702b;
--m3-secondary-container: #e2e6dc;
--m3-on-secondary-container: #20251f;
--m3-tertiary: #877b68;
--m3-on-tertiary: #ffffff;
--m3-tertiary-container: #ecefa7;
--m3-on-tertiary-container: #202200;
--m3-error: #b3261e;
--m3-tertiary-container: #ece2d2;
--m3-on-tertiary-container: #2d261a;
--m3-error: #a25f5b;
--m3-on-error: #ffffff;
--m3-error-container: #f9dedc;
--m3-success: #4f7a29;
--m3-success-container: #d2efb2;
--m3-warning: #755b00;
--m3-warning-container: #ffe19a;
--m3-background: #fbfbff;
--m3-surface: #fbfbff;
--m3-error-container: #f2ddda;
--m3-warning: #806b43;
--m3-warning-container: #f0e4c7;
--m3-background: #fbfcf8;
--m3-surface: #fbfcf8;
--m3-surface-container-lowest: #ffffff;
--m3-surface-container-low: #f4f6fb;
--m3-surface-container: #eef2f8;
--m3-surface-container-high: #e7ebf3;
--m3-surface-container-highest: #dde4ef;
--m3-on-surface: #1a1b1f;
--m3-on-surface-variant: #464b55;
--m3-outline: #737986;
--m3-outline-variant: #c6ccd8;
--m3-surface-container-low: #f4f7ef;
--m3-surface-container: #edf1e8;
--m3-surface-container-high: #e4e9de;
--m3-surface-container-highest: #dbe2d5;
--m3-on-surface: #1b1d19;
--m3-on-surface-variant: #464b43;
--m3-outline: #747b70;
--m3-outline-variant: #c9d0c3;
--m3-divider: color-mix(in srgb, var(--m3-outline-variant) 78%, transparent);
--m3-soft-outline: color-mix(in srgb, var(--m3-outline-variant) 88%, transparent);
--m3-input-outline: color-mix(in srgb, var(--m3-outline) 82%, transparent);
--m3-primary-state: color-mix(in srgb, var(--m3-primary) 14%, transparent);
--m3-secondary-state: color-mix(in srgb, var(--m3-secondary) 12%, transparent);
--m3-tertiary-state: color-mix(in srgb, var(--m3-tertiary) 12%, transparent);
--m3-warning-state: color-mix(in srgb, var(--m3-warning) 12%, transparent);
--m3-shadow: 0 1px 2px rgba(29, 27, 32, 0.14), 0 2px 6px rgba(29, 27, 32, 0.10);
--m3-elevated-shadow: 0 3px 8px rgba(29, 27, 32, 0.13), 0 12px 24px rgba(29, 27, 32, 0.08);
}
.dark {
--m3-primary: #b9c6ff;
--m3-on-primary: #0a2f68;
--m3-primary-container: #28477f;
--m3-on-primary-container: #dbe4ff;
--m3-secondary: #c4c9d8;
--m3-on-secondary: #2d313b;
--m3-secondary-container: #434754;
--m3-on-secondary-container: #e0e5f2;
--m3-tertiary: #d7da7d;
--m3-on-tertiary: #363800;
--m3-tertiary-container: #535611;
--m3-on-tertiary-container: #ecefa7;
--m3-error: #f2b8b5;
--m3-on-error: #601410;
--m3-error-container: #8c1d18;
--m3-success: #b7dda0;
--m3-success-container: #385421;
--m3-warning: #e7c16d;
--m3-warning-container: #5d4100;
--m3-background: #111318;
--m3-surface: #111318;
--m3-surface-container-lowest: #0b0d12;
--m3-surface-container-low: #1a1c22;
--m3-surface-container: #1f2229;
--m3-surface-container-high: #292d35;
--m3-surface-container-highest: #343941;
--m3-on-surface: #e4e7ef;
--m3-on-surface-variant: #c6ccd8;
--m3-outline: #9096a3;
--m3-outline-variant: #464b55;
--m3-primary: #b7c9b8;
--m3-on-primary: #243429;
--m3-primary-container: #3f5745;
--m3-on-primary-container: #dce7dc;
--m3-secondary: #c6cbbf;
--m3-on-secondary: #2e332b;
--m3-secondary-container: #454b40;
--m3-on-secondary-container: #e2e6dc;
--m3-tertiary: #d4c5aa;
--m3-on-tertiary: #3b2f20;
--m3-tertiary-container: #5d503e;
--m3-on-tertiary-container: #f1e4cf;
--m3-error: #e6b8b4;
--m3-on-error: #5f1715;
--m3-error-container: #7c3430;
--m3-warning: #dcc48e;
--m3-warning-container: #5a4828;
--m3-background: #111510;
--m3-surface: #111510;
--m3-surface-container-lowest: #0b0e0a;
--m3-surface-container-low: #1a1f18;
--m3-surface-container: #20261e;
--m3-surface-container-high: #2a3128;
--m3-surface-container-highest: #343d31;
--m3-on-surface: #e5e9df;
--m3-on-surface-variant: #c7cec1;
--m3-outline: #92998e;
--m3-outline-variant: #464d43;
--m3-shadow: 0 1px 2px rgba(0, 0, 0, 0.36), 0 4px 12px rgba(0, 0, 0, 0.28);
--m3-elevated-shadow: 0 4px 12px rgba(0, 0, 0, 0.45), 0 16px 32px rgba(0, 0, 0, 0.32);
}
@@ -105,8 +107,8 @@ header.dark\:bg-dark,
footer.bg-white,
footer.dark\:bg-dark {
background: var(--m3-surface-container-low) !important;
border-color: var(--m3-outline-variant) !important;
box-shadow: var(--m3-shadow);
border-color: var(--m3-divider) !important;
box-shadow: none !important;
}
aside.bg-white,
@@ -114,9 +116,19 @@ aside.dark\:bg-dark,
main.bg-slate-50,
main.dark\:bg-\[\#0f0f0f\] {
background: var(--m3-background) !important;
border-color: var(--m3-outline-variant) !important;
border-color: var(--m3-divider) !important;
}
h2,
h3,
h4,
label,
summary {
color: var(--m3-on-surface-variant) !important;
}
h3.font-bold,
h4.font-bold,
header h1 {
background: none !important;
color: var(--m3-on-surface) !important;
@@ -124,41 +136,110 @@ header h1 {
font-weight: 700;
}
header h1.text-transparent {
color: var(--m3-on-surface) !important;
}
h2,
h3,
label,
summary {
color: var(--m3-on-surface-variant) !important;
}
h3.font-bold,
h4.font-bold {
color: var(--m3-on-surface) !important;
}
hr {
border-color: var(--m3-divider) !important;
}
.bg-white,
.dark .dark\:bg-dark,
.dark .lg\:dark\:bg-dark,
.bg-slate-50,
.dark .dark\:bg-gray-900,
.dark .dark\:bg-gray-900\/50,
.dark .dark\:bg-gray-900\/30 {
background-color: var(--m3-surface-container-low) !important;
}
.bg-slate-50\/50,
.bg-slate-100,
.dark .dark\:bg-gray-800,
.dark .dark\:bg-gray-800\/50,
.dark .dark\:bg-gray-800\/30 {
background-color: var(--m3-surface-container) !important;
}
details,
main > div,
.overflow-x-auto.rounded-xl,
.space-y-3.bg-slate-100,
.flex.items-center.flex-wrap.gap-4 {
background-color: var(--m3-surface-container) !important;
border-color: var(--m3-soft-outline) !important;
box-shadow: none !important;
}
.border,
.border-2 {
border-color: var(--m3-outline-variant) !important;
}
.border-b,
.border-t,
.border-l,
.border-r,
.lg\:border,
.divide-y > :not([hidden]) ~ :not([hidden]) {
border-color: var(--m3-divider) !important;
}
.shadow-md,
.shadow-lg,
.lg\:shadow-md,
.shadow-inner {
box-shadow: var(--m3-shadow) !important;
}
#bodePlot,
#timePlot,
.js-plotly-plot {
background-color: var(--m3-surface-container-lowest) !important;
}
.overflow-x-auto:has(#bodePlot),
.overflow-x-auto:has(#timePlot) {
background-color: var(--m3-surface-container-lowest) !important;
}
.text-slate-900,
.text-slate-800,
.dark .dark\:text-gray-100,
.dark .dark\:text-gray-200 {
color: var(--m3-on-surface) !important;
}
.text-slate-700,
.text-slate-600,
.text-slate-500,
.text-slate-400,
.dark .dark\:text-gray-300,
.dark .dark\:text-gray-400,
.dark .dark\:text-gray-500 {
color: var(--m3-on-surface-variant) !important;
}
select,
input[type="number"],
textarea {
background: var(--m3-surface-container-lowest) !important;
border-color: var(--m3-outline) !important;
border-color: var(--m3-input-outline) !important;
border-radius: 12px !important;
color: var(--m3-on-surface) !important;
box-shadow: none;
}
select:hover,
input[type="number"]:hover,
textarea:hover {
border-color: var(--m3-outline) !important;
}
select:focus,
input[type="number"]:focus,
textarea:focus {
textarea:focus,
.role-focus-primary:focus,
.role-focus-primary:focus-visible {
border-color: var(--m3-primary) !important;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--m3-primary) 28%, transparent) !important;
box-shadow: 0 0 0 3px var(--m3-primary-state) !important;
}
button,
@@ -183,164 +264,44 @@ label.cursor-pointer:active {
transform: translateY(0);
}
button.bg-blue-600,
button.bg-emerald-600,
button.bg-slate-700,
label.bg-blue-600 {
background: var(--m3-primary) !important;
color: var(--m3-on-primary) !important;
box-shadow: var(--m3-shadow) !important;
}
button.bg-blue-600:hover,
button.bg-emerald-600:hover,
button.bg-slate-700:hover,
label.bg-blue-600:hover {
background: color-mix(in srgb, var(--m3-primary) 88%, var(--m3-on-primary) 12%) !important;
box-shadow: var(--m3-elevated-shadow) !important;
}
button.bg-slate-200,
button.dark\:bg-gray-800,
button.bg-amber-100,
button.bg-blue-100,
button.bg-orange-100,
button.bg-indigo-100,
button.bg-emerald-100 {
button.dark\:bg-gray-800 {
background: var(--m3-secondary-container) !important;
color: var(--m3-on-secondary-container) !important;
}
button.bg-slate-200:hover,
button.dark\:bg-gray-800:hover,
button.bg-amber-100:hover,
button.bg-blue-100:hover,
button.bg-orange-100:hover,
button.bg-indigo-100:hover,
button.bg-emerald-100:hover {
background: color-mix(in srgb, var(--m3-secondary-container) 86%, var(--m3-primary) 14%) !important;
.hover\:bg-slate-600:hover,
.hover\:bg-slate-300:hover,
.dark .dark\:hover\:bg-gray-700:hover {
background: color-mix(in srgb, var(--m3-secondary-container) 82%, var(--m3-primary) 18%) !important;
}
.bg-white,
.dark .dark\:bg-dark,
.dark .lg\:dark\:bg-dark,
.bg-slate-50,
.dark .dark\:bg-gray-900,
.dark .dark\:bg-gray-900\/50,
.dark .dark\:bg-gray-900\/30 {
background-color: var(--m3-surface-container-low) !important;
button.border.bg-white,
button.border.bg-slate-200,
button.border.dark\:bg-gray-800,
label.border.bg-white {
border-color: var(--m3-soft-outline) !important;
}
.bg-slate-50\/50,
.bg-slate-100,
.dark .dark\:bg-gray-800,
.dark .dark\:bg-gray-800\/50,
.dark .dark\:bg-gray-800\/30 {
background-color: var(--m3-surface-container) !important;
.bg-slate-700 {
background: var(--m3-secondary) !important;
color: var(--m3-on-secondary) !important;
}
.border,
.border-b,
.border-r,
.border-t,
.lg\:border {
border-color: var(--m3-outline-variant) !important;
.rounded,
.rounded-lg,
.rounded-xl,
details,
select,
textarea,
input[type="number"] {
border-radius: 16px !important;
}
.shadow-md,
.shadow-lg,
.lg\:shadow-md,
.shadow-inner {
box-shadow: var(--m3-shadow) !important;
}
.text-slate-900,
.text-slate-800,
.dark .dark\:text-gray-100,
.dark .dark\:text-gray-200 {
color: var(--m3-on-surface) !important;
}
.text-slate-700,
.text-slate-600,
.text-slate-500,
.text-slate-400,
.dark .dark\:text-gray-300,
.dark .dark\:text-gray-400,
.dark .dark\:text-gray-500 {
color: var(--m3-on-surface-variant) !important;
}
.text-blue-600,
.text-blue-700,
.text-blue-800,
.dark .dark\:text-blue-300,
.dark .dark\:text-blue-400 {
color: var(--m3-primary) !important;
}
.text-emerald-600,
.text-emerald-700,
.dark .dark\:text-emerald-300,
.dark .dark\:text-emerald-400 {
color: var(--m3-success) !important;
}
.text-orange-600,
.text-orange-700,
.text-orange-800,
.dark .dark\:text-orange-300,
.dark .dark\:text-orange-400,
.text-indigo-600,
.text-indigo-700,
.dark .dark\:text-indigo-300,
.dark .dark\:text-indigo-400 {
color: var(--m3-secondary) !important;
}
.text-red-600,
.text-red-700,
.dark .dark\:text-red-400 {
color: var(--m3-error) !important;
}
.bg-blue-50,
.bg-blue-100,
.dark .dark\:bg-blue-900\/20,
.dark .dark\:bg-blue-900\/30 {
background-color: var(--m3-primary-container) !important;
border-color: color-mix(in srgb, var(--m3-primary) 24%, transparent) !important;
color: var(--m3-on-primary-container) !important;
}
.bg-emerald-50,
.bg-emerald-100,
.dark .dark\:bg-emerald-900\/20,
.dark .dark\:bg-emerald-900\/30 {
background-color: color-mix(in srgb, var(--m3-success-container) 72%, var(--m3-surface-container-low)) !important;
border-color: color-mix(in srgb, var(--m3-success) 24%, transparent) !important;
}
.bg-orange-50,
.bg-orange-100,
.bg-indigo-50,
.bg-indigo-100,
.dark .dark\:bg-orange-900\/20,
.dark .dark\:bg-orange-900\/30,
.dark .dark\:bg-indigo-900\/20,
.dark .dark\:bg-indigo-900\/30 {
background-color: color-mix(in srgb, var(--m3-secondary-container) 76%, var(--m3-surface-container-lowest)) !important;
border-color: color-mix(in srgb, var(--m3-secondary) 24%, transparent) !important;
color: var(--m3-on-secondary-container) !important;
}
.bg-amber-50,
.bg-amber-100,
.dark .dark\:bg-amber-900\/20,
.dark .dark\:bg-amber-900\/30 {
background-color: var(--m3-warning-container) !important;
border-color: color-mix(in srgb, var(--m3-warning) 24%, transparent) !important;
color: var(--m3-warning) !important;
.rounded-full {
border-radius: 999px !important;
}
input[type=range] {
@@ -364,78 +325,203 @@ input[type=range]::-webkit-slider-thumb {
background: color-mix(in srgb, var(--m3-outline) 52%, transparent);
}
.rounded,
.rounded-lg,
.rounded-xl,
details,
select,
textarea,
input[type="number"] {
border-radius: 16px !important;
}
.rounded-full {
border-radius: 999px !important;
}
details,
main > div,
.overflow-x-auto.rounded-xl,
.space-y-3.bg-slate-100,
.flex.items-center.flex-wrap.gap-4 {
background-color: var(--m3-surface-container) !important;
border-color: var(--m3-outline-variant) !important;
}
tr.bg-slate-50,
.hover\:bg-slate-50\/50:hover,
.dark .dark\:hover\:bg-gray-800\/30:hover {
background-color: var(--m3-surface-container-high) !important;
}
.bg-gradient-to-r,
.from-blue-600,
.to-emerald-600 {
background-image: linear-gradient(90deg, var(--m3-primary), var(--m3-tertiary)) !important;
.role-theme-icon {
color: var(--m3-tertiary) !important;
}
.from-blue-500\/5,
.to-purple-500\/5 {
--tw-gradient-from: color-mix(in srgb, var(--m3-primary) 7%, transparent) !important;
--tw-gradient-to: color-mix(in srgb, var(--m3-tertiary) 7%, transparent) !important;
.role-text-on-fill {
color: var(--m3-on-primary) !important;
}
.accent-blue-500 {
accent-color: var(--m3-primary);
}
.accent-orange-500 {
accent-color: var(--m3-secondary);
}
details .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > div {
background: #ffffff !important;
border-color: var(--m3-outline-variant) !important;
color: var(--m3-on-surface) !important;
}
.dark details .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > div {
background: var(--m3-surface-container-lowest) !important;
}
details .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] span,
details .grid.grid-cols-\[2rem_1fr_2rem\] input,
details .grid.grid-cols-\[2rem_1fr_2rem\] + span,
details button.touch-none span:last-child {
.role-text-primary {
color: var(--m3-primary) !important;
}
details .grid.grid-cols-\[2rem_1fr_2rem\] input {
background: #ffffff !important;
border-color: var(--m3-outline-variant) !important;
.role-text-primary-muted {
color: color-mix(in srgb, var(--m3-primary) 78%, var(--m3-on-surface-variant)) !important;
}
.dark details .grid.grid-cols-\[2rem_1fr_2rem\] input {
.role-text-secondary {
color: var(--m3-secondary) !important;
}
.role-text-secondary-muted {
color: color-mix(in srgb, var(--m3-secondary) 76%, var(--m3-on-surface-variant)) !important;
}
.role-text-tertiary {
color: var(--m3-tertiary) !important;
}
.role-text-tertiary-muted {
color: color-mix(in srgb, var(--m3-tertiary) 76%, var(--m3-on-surface-variant)) !important;
}
.role-text-warning {
color: var(--m3-warning) !important;
}
.role-text-error {
color: var(--m3-error) !important;
}
.role-bg-primary,
button.role-bg-primary,
label.role-bg-primary {
background: var(--m3-primary) !important;
color: var(--m3-on-primary) !important;
box-shadow: var(--m3-shadow) !important;
}
.role-bg-primary:hover,
.role-hover-primary:hover {
background: color-mix(in srgb, var(--m3-primary) 88%, var(--m3-on-primary) 12%) !important;
box-shadow: var(--m3-elevated-shadow) !important;
}
.role-bg-primary-soft {
background-color: color-mix(in srgb, var(--m3-primary-container) 76%, var(--m3-surface-container-lowest)) !important;
border-color: color-mix(in srgb, var(--m3-primary) 28%, var(--m3-outline-variant)) !important;
color: var(--m3-on-primary-container) !important;
}
.role-hover-primary-soft:hover {
background-color: color-mix(in srgb, var(--m3-primary-container) 82%, var(--m3-primary) 18%) !important;
}
.role-bg-secondary-soft {
background-color: color-mix(in srgb, var(--m3-secondary-container) 76%, var(--m3-surface-container-lowest)) !important;
border-color: color-mix(in srgb, var(--m3-secondary) 28%, var(--m3-outline-variant)) !important;
color: var(--m3-on-secondary-container) !important;
}
.role-hover-secondary-soft:hover {
background-color: color-mix(in srgb, var(--m3-secondary-container) 82%, var(--m3-secondary) 18%) !important;
}
.role-bg-tertiary-soft {
background-color: color-mix(in srgb, var(--m3-tertiary-container) 76%, var(--m3-surface-container-lowest)) !important;
border-color: color-mix(in srgb, var(--m3-tertiary) 28%, var(--m3-outline-variant)) !important;
color: var(--m3-on-tertiary-container) !important;
}
.role-hover-tertiary-soft:hover {
background-color: color-mix(in srgb, var(--m3-tertiary-container) 82%, var(--m3-tertiary) 18%) !important;
}
.role-bg-warning-soft {
background-color: color-mix(in srgb, var(--m3-warning-container) 66%, var(--m3-surface-container-lowest)) !important;
border-color: color-mix(in srgb, var(--m3-warning) 30%, var(--m3-outline-variant)) !important;
color: var(--m3-warning) !important;
}
.role-hover-warning-soft:hover {
background-color: color-mix(in srgb, var(--m3-warning-container) 78%, var(--m3-warning) 14%) !important;
}
.role-bg-error-soft {
background-color: var(--m3-error-container) !important;
color: var(--m3-error) !important;
}
.role-dot-primary {
background-color: var(--m3-primary) !important;
}
.role-border-primary,
.role-active-border-primary:active {
border-color: var(--m3-primary) !important;
}
.role-border-primary-soft {
border-color: color-mix(in srgb, var(--m3-primary) 24%, transparent) !important;
}
.role-border-secondary,
.role-active-border-secondary:active {
border-color: var(--m3-secondary) !important;
}
.role-border-secondary-soft {
border-color: color-mix(in srgb, var(--m3-secondary) 24%, transparent) !important;
}
.role-border-tertiary,
.role-active-border-tertiary:active {
border-color: var(--m3-tertiary) !important;
}
.role-border-tertiary-soft {
border-color: color-mix(in srgb, var(--m3-tertiary) 24%, transparent) !important;
}
.role-border-warning-soft {
border-color: color-mix(in srgb, var(--m3-warning) 24%, transparent) !important;
}
.role-border-error {
border-color: color-mix(in srgb, var(--m3-error) 34%, transparent) !important;
}
.role-ring-primary,
.role-active-ring-primary:active,
.role-focus-ring-primary {
--tw-ring-color: var(--m3-primary-state) !important;
}
.role-ring-secondary,
.role-active-ring-secondary:active {
--tw-ring-color: var(--m3-secondary-state) !important;
}
.role-active-ring-tertiary:active {
--tw-ring-color: var(--m3-tertiary-state) !important;
}
.ring-1 {
box-shadow: 0 0 0 1px var(--m3-primary-state) !important;
}
.role-range-primary {
accent-color: var(--m3-primary);
}
.role-range-secondary {
accent-color: var(--m3-secondary);
}
.role-shadow-primary:hover {
box-shadow: var(--m3-elevated-shadow) !important;
}
.role-hover-error:hover {
color: color-mix(in srgb, var(--m3-error) 78%, var(--m3-on-surface)) !important;
}
.role-surface-gradient {
background-image: linear-gradient(
135deg,
color-mix(in srgb, var(--m3-primary) 7%, transparent),
color-mix(in srgb, var(--m3-tertiary) 6%, transparent)
) !important;
}
details .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > div,
details .grid.grid-cols-\[2rem_1fr_2rem\] input,
details button.touch-none {
background: #ffffff !important;
border-color: var(--m3-soft-outline) !important;
}
.dark details .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > div,
.dark details .grid.grid-cols-\[2rem_1fr_2rem\] input,
.dark details button.touch-none {
background: var(--m3-surface-container-lowest) !important;
}
@@ -443,12 +529,3 @@ details .grid.grid-cols-\[2rem_1fr_2rem\] button {
background: var(--m3-secondary-container) !important;
color: var(--m3-on-secondary-container) !important;
}
details button.touch-none {
background: #ffffff !important;
border-color: var(--m3-outline-variant) !important;
}
.dark details button.touch-none {
background: var(--m3-surface-container-lowest) !important;
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
File diff suppressed because one or more lines are too long