feat: implement dea module
This commit is contained in:
Executable
+2
@@ -0,0 +1,2 @@
|
||||
"""Core package for the Difference Equation Analyzer API."""
|
||||
|
||||
Executable
+26
@@ -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
@@ -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
|
||||
|
||||
Executable
+72
@@ -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()
|
||||
|
||||
Executable
+92
@@ -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
@@ -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)
|
||||
|
||||
Executable
+46
@@ -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."},
|
||||
)
|
||||
)
|
||||
|
||||
Executable
+50
@@ -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
@@ -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
@@ -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) / 2,r = (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) / 2,r = (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">×</button>
|
||||
class="ml-2 role-hover-error">×</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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user