样式: 优化登录页与功率曲线坐标轴

- 登录页调整为浅色动态背景和浅色表单
- 图表刻度改为朝内并同步导出图片
- 网格间隔严格使用编辑值并补充回归测试
This commit is contained in:
cloud
2026-09-16 17:17:23 +08:00
parent 58be5a34f6
commit 82bce95490
4 changed files with 94 additions and 65 deletions
+35 -41
View File
@@ -17,6 +17,8 @@ import {
import { import {
alignDesignCurveToActualCurve, alignDesignCurveToActualCurve,
buildPowerCurveModel, buildPowerCurveModel,
getAxisMaximum,
getAxisTicks,
getDrawableActualCurve, getDrawableActualCurve,
hasDrawablePowerCurveData, hasDrawablePowerCurveData,
} from '../utils/powerCurveModel'; } from '../utils/powerCurveModel';
@@ -159,8 +161,8 @@ const DEFAULT_CHART_OPTIONS = {
show_legend: true, show_legend: true,
show_filtered: false, show_filtered: false,
scatter_size: 2, scatter_size: 2,
x_tick_interval: '', x_tick_interval: '1',
y_tick_interval: '', y_tick_interval: '200',
scatter_color: '#2563eb', scatter_color: '#2563eb',
scatter_opacity: 42, scatter_opacity: 42,
show_scatter: true, show_scatter: true,
@@ -199,6 +201,12 @@ function normalizeChartOptions(options = {}) {
...DEFAULT_CHART_OPTIONS, ...DEFAULT_CHART_OPTIONS,
...currentOptions, ...currentOptions,
text_scale: getTextScale(textScale), text_scale: getTextScale(textScale),
x_tick_interval: Number(currentOptions.x_tick_interval) > 0
? currentOptions.x_tick_interval
: DEFAULT_CHART_OPTIONS.x_tick_interval,
y_tick_interval: Number(currentOptions.y_tick_interval) > 0
? currentOptions.y_tick_interval
: DEFAULT_CHART_OPTIONS.y_tick_interval,
}; };
} }
@@ -738,39 +746,6 @@ function drawSmoothLine(context, points, toX, toY, style) {
context.restore(); context.restore();
} }
function getPositiveTickInterval(value) {
const interval = Number(value);
return Number.isFinite(interval) && interval > 0 ? interval : null;
}
function getNiceTickInterval(min, max) {
const range = Math.max(Math.abs(max - min), 1);
const raw = range / 8;
const magnitude = 10 ** Math.floor(Math.log10(raw));
const normalized = raw / magnitude;
const factor = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
return factor * magnitude;
}
function resolveTickInterval(min, max, configuredInterval) {
return getPositiveTickInterval(configuredInterval) || getNiceTickInterval(min, max);
}
function getAxisMaximum(max, configuredInterval) {
const interval = resolveTickInterval(0, max, configuredInterval);
return Math.max(interval, Math.ceil(Math.max(max, 0) / interval) * interval);
}
function getAxisTicks(min, max, configuredInterval) {
const interval = resolveTickInterval(min, max, configuredInterval);
const first = Math.ceil((min - 1e-9) / interval) * interval;
const ticks = [];
for (let value = first; value <= max + interval * 1e-6 && ticks.length < 120; value += interval) {
ticks.push(Number(value.toFixed(10)));
}
return ticks;
}
function formatTick(value) { function formatTick(value) {
if (!Number.isFinite(value)) return ''; if (!Number.isFinite(value)) return '';
return Math.abs(value) >= 100 || Number.isInteger(value) ? value.toFixed(0) : value.toFixed(2).replace(/0+$/, '').replace(/\.$/, ''); return Math.abs(value) >= 100 || Number.isInteger(value) ? value.toFixed(0) : value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
@@ -840,6 +815,23 @@ function renderPowerCurvePng({ actualCurve, designCurve, scatterPoints, filtered
context.strokeStyle = gridColor; context.strokeStyle = gridColor;
context.lineWidth = 1; context.lineWidth = 1;
context.strokeRect(padding.left, padding.top, plotWidth, plotHeight); context.strokeRect(padding.left, padding.top, plotWidth, plotHeight);
context.strokeStyle = '#94a3b8';
context.lineWidth = 2;
const inwardTickSize = 10;
for (const xValue of getAxisTicks(xMin, xMax, options.x_tick_interval)) {
const x = toX(xValue);
context.beginPath();
context.moveTo(x, padding.top + plotHeight);
context.lineTo(x, padding.top + plotHeight - inwardTickSize);
context.stroke();
}
for (const yValue of getAxisTicks(yMin, yMax, options.y_tick_interval)) {
const y = toY(yValue);
context.beginPath();
context.moveTo(padding.left, y);
context.lineTo(padding.left + inwardTickSize, y);
context.stroke();
}
context.fillStyle = textColor; context.fillStyle = textColor;
for (const xValue of getAxisTicks(xMin, xMax, options.x_tick_interval)) { for (const xValue of getAxisTicks(xMin, xMax, options.x_tick_interval)) {
context.textAlign = 'center'; context.textAlign = 'center';
@@ -1353,7 +1345,7 @@ function PowerCurveChart({
label: activeChartOptions.x_axis_label, label: activeChartOptions.x_axis_label,
stroke: activeChartOptions.text_color, stroke: activeChartOptions.text_color,
size: xAxisSize, size: xAxisSize,
gap: 8, gap: 18,
labelSize: axisLabelSize, labelSize: axisLabelSize,
labelGap: 10, labelGap: 10,
font: `${screenTypography.tick}px "Segoe UI", sans-serif`, font: `${screenTypography.tick}px "Segoe UI", sans-serif`,
@@ -1361,12 +1353,13 @@ function PowerCurveChart({
splits: (u, axisIdx, min, max) => getAxisTicks(min, max, activeChartOptions.x_tick_interval), splits: (u, axisIdx, min, max) => getAxisTicks(min, max, activeChartOptions.x_tick_interval),
values: (u, splits) => splits.map(formatTick), values: (u, splits) => splits.map(formatTick),
grid: { show: activeChartOptions.show_grid, stroke: '#d8e0ea', width: 1 }, grid: { show: activeChartOptions.show_grid, stroke: '#d8e0ea', width: 1 },
ticks: { show: true, stroke: '#94a3b8', width: 1, size: -8 },
}, },
{ {
label: activeChartOptions.y_axis_label, label: activeChartOptions.y_axis_label,
stroke: activeChartOptions.text_color, stroke: activeChartOptions.text_color,
size: yAxisSize, size: yAxisSize,
gap: 8, gap: 18,
labelSize: axisLabelSize, labelSize: axisLabelSize,
labelGap: 10, labelGap: 10,
font: `${screenTypography.tick}px "Segoe UI", sans-serif`, font: `${screenTypography.tick}px "Segoe UI", sans-serif`,
@@ -1374,6 +1367,7 @@ function PowerCurveChart({
splits: (u, axisIdx, min, max) => getAxisTicks(min, max, activeChartOptions.y_tick_interval), splits: (u, axisIdx, min, max) => getAxisTicks(min, max, activeChartOptions.y_tick_interval),
values: (u, splits) => splits.map(formatTick), values: (u, splits) => splits.map(formatTick),
grid: { show: activeChartOptions.show_grid, stroke: '#d8e0ea', width: 1 }, grid: { show: activeChartOptions.show_grid, stroke: '#d8e0ea', width: 1 },
ticks: { show: true, stroke: '#94a3b8', width: 1, size: -8 },
}, },
], ],
series: [ series: [
@@ -3000,23 +2994,23 @@ export default function HomePage({ activeSection = 'settings' }) {
<b>{chartOptions.scatter_size}</b> <b>{chartOptions.scatter_size}</b>
</label> </label>
<label> <label>
<span>X 刻度</span> <span>X 网格间隔</span>
<input <input
type="number" type="number"
min="0.001" min="0.001"
step="any" step="any"
placeholder="自动" placeholder="请输入间隔"
value={chartOptions.x_tick_interval} value={chartOptions.x_tick_interval}
onChange={(event) => handleChartOptionChange('x_tick_interval', event.target.value)} onChange={(event) => handleChartOptionChange('x_tick_interval', event.target.value)}
/> />
</label> </label>
<label> <label>
<span>Y 刻度</span> <span>Y 网格间隔</span>
<input <input
type="number" type="number"
min="0.001" min="0.001"
step="any" step="any"
placeholder="自动" placeholder="请输入间隔"
value={chartOptions.y_tick_interval} value={chartOptions.y_tick_interval}
onChange={(event) => handleChartOptionChange('y_tick_interval', event.target.value)} onChange={(event) => handleChartOptionChange('y_tick_interval', event.target.value)}
/> />
+26 -24
View File
@@ -5,7 +5,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100vh; height: 100vh;
background: #080812; background: linear-gradient(145deg, #f8fbff 0%, #eef4ff 48%, #f5f3ff 100%);
overflow: hidden; overflow: hidden;
} }
@@ -14,6 +14,8 @@
position: absolute; position: absolute;
inset: 0; inset: 0;
z-index: 0; z-index: 0;
opacity: 0.46;
filter: invert(1) hue-rotate(180deg) saturate(0.8);
} }
/* ─── 中心径向辉光 ─── */ /* ─── 中心径向辉光 ─── */
@@ -23,10 +25,10 @@
z-index: 1; z-index: 1;
background: background:
radial-gradient(ellipse 60% 50% at 50% 50%, radial-gradient(ellipse 60% 50% at 50% 50%,
rgba(99, 102, 241, 0.08) 0%, rgba(99, 102, 241, 0.12) 0%,
transparent 70%), transparent 70%),
radial-gradient(ellipse 40% 30% at 50% 45%, radial-gradient(ellipse 40% 30% at 50% 45%,
rgba(139, 92, 246, 0.06) 0%, rgba(139, 92, 246, 0.08) 0%,
transparent 60%); transparent 60%);
pointer-events: none; pointer-events: none;
} }
@@ -44,7 +46,7 @@
position: absolute; position: absolute;
bottom: -10px; bottom: -10px;
border-radius: 50%; border-radius: 50%;
background: #818cf8; background: #6366f1;
filter: blur(1px); filter: blur(1px);
animation: floatUp linear infinite; animation: floatUp linear infinite;
} }
@@ -71,15 +73,15 @@
position: relative; position: relative;
z-index: 10; z-index: 10;
width: 400px; width: 400px;
background: rgba(18, 18, 30, 0.75); background: rgba(255, 255, 255, 0.86);
border: 1px solid rgba(99, 102, 241, 0.15); border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 16px; border-radius: 16px;
padding: 44px 36px 32px; padding: 44px 36px 32px;
backdrop-filter: blur(20px) saturate(1.4); backdrop-filter: blur(22px) saturate(1.2);
box-shadow: box-shadow:
0 0 60px rgba(99, 102, 241, 0.08), 0 0 60px rgba(99, 102, 241, 0.08),
0 0 120px rgba(139, 92, 246, 0.04), 0 0 120px rgba(139, 92, 246, 0.05),
0 24px 48px rgba(0, 0, 0, 0.5); 0 24px 60px rgba(51, 65, 85, 0.16);
animation: cardEnter 0.8s ease-out; animation: cardEnter 0.8s ease-out;
} }
@@ -139,7 +141,7 @@
font-weight: 700; font-weight: 700;
text-align: center; text-align: center;
margin: 0 0 6px; margin: 0 0 6px;
background: linear-gradient(135deg, #e0e7ff, #c7d2fe, #a5b4fc); background: linear-gradient(135deg, #1e3a8a, #4f46e5, #7c3aed);
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
@@ -148,7 +150,7 @@
.subtitle { .subtitle {
font-size: 16px; font-size: 16px;
color: #6b7280; color: #64748b;
text-align: center; text-align: center;
margin: 16px 0 22px; margin: 16px 0 22px;
letter-spacing: 6px; letter-spacing: 6px;
@@ -162,7 +164,7 @@
.label { .label {
display: block; display: block;
font-size: 15px; font-size: 15px;
color: #8b8fa3; color: #475569;
margin-bottom: 8px; margin-bottom: 8px;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
@@ -170,10 +172,10 @@
.input { .input {
width: 100%; width: 100%;
padding: 11px 14px; padding: 11px 14px;
background: rgba(20, 20, 36, 0.8); background: rgba(248, 250, 252, 0.92);
border: 1px solid rgba(99, 102, 241, 0.12); border: 1px solid #cbd5e1;
border-radius: 8px; border-radius: 8px;
color: #d1d5db; color: #0f172a;
font-size: 17px; font-size: 17px;
outline: none; outline: none;
transition: all 0.2s ease; transition: all 0.2s ease;
@@ -181,13 +183,13 @@
} }
.input:focus { .input:focus {
border-color: rgba(99, 102, 241, 0.5); border-color: #818cf8;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.08); box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12);
background: rgba(20, 20, 36, 1); background: #ffffff;
} }
.input::placeholder { .input::placeholder {
color: #4a4e5a; color: #94a3b8;
} }
.inputWrap { .inputWrap {
@@ -207,7 +209,7 @@
border: none; border: none;
padding: 4px; padding: 4px;
cursor: pointer; cursor: pointer;
color: #4a4e5a; color: #64748b;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -221,12 +223,12 @@
/* ─── 错误提示 ─── */ /* ─── 错误提示 ─── */
.error { .error {
font-size: 13px; font-size: 13px;
color: #f87171; color: #b91c1c;
margin-bottom: 16px; margin-bottom: 16px;
text-align: center; text-align: center;
padding: 8px 12px; padding: 8px 12px;
background: rgba(239, 68, 68, 0.06); background: #fff1f2;
border: 1px solid rgba(239, 68, 68, 0.15); border: 1px solid #fecaca;
border-radius: 6px; border-radius: 6px;
} }
@@ -298,6 +300,6 @@
.footerText { .footerText {
font-size: 13px; font-size: 13px;
color: #4a4e5a; color: #64748b;
letter-spacing: 1px; letter-spacing: 1px;
} }
@@ -4,6 +4,30 @@ function validNumber(value) {
return Number.isFinite(value); return Number.isFinite(value);
} }
function getPositiveTickInterval(value) {
const interval = Number(value);
return Number.isFinite(interval) && interval > 0 ? interval : null;
}
export function resolveTickInterval(configuredInterval, fallbackInterval = 1) {
return getPositiveTickInterval(configuredInterval) || fallbackInterval;
}
export function getAxisMaximum(max, configuredInterval) {
const interval = resolveTickInterval(configuredInterval);
return Math.max(interval, Math.ceil(Math.max(max, 0) / interval) * interval);
}
export function getAxisTicks(min, max, configuredInterval) {
const interval = resolveTickInterval(configuredInterval);
const first = Math.ceil((min - 1e-9) / interval) * interval;
const ticks = [];
for (let value = first; value <= max + interval * 1e-6 && ticks.length < 120; value += interval) {
ticks.push(Number(value.toFixed(10)));
}
return ticks;
}
export function normalizeDesignCurve(points = []) { export function normalizeDesignCurve(points = []) {
const grouped = new Map(); const grouped = new Map();
for (const point of points) { for (const point of points) {
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
buildActualCurveFromDesign, buildActualCurveFromDesign,
getAxisTicks,
getDrawableActualCurve, getDrawableActualCurve,
hasDrawablePowerCurveData, hasDrawablePowerCurveData,
} from './powerCurveModel.js'; } from './powerCurveModel.js';
@@ -32,6 +33,14 @@ test('getDrawableActualCurve skips empty design bins and keeps adjacent valid po
); );
}); });
test('uses configured grid intervals exactly', () => {
assert.deepEqual(
getAxisTicks(0, 2, 0.5),
[0, 0.5, 1, 1.5, 2],
);
assert.deepEqual(getAxisTicks(0, 800, 200), [0, 200, 400, 600, 800]);
});
test('allows a chart to render when only scatter points are available', () => { test('allows a chart to render when only scatter points are available', () => {
assert.equal(hasDrawablePowerCurveData({ assert.equal(hasDrawablePowerCurveData({
actualCurve: [], actualCurve: [],