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

- 登录页调整为浅色动态背景和浅色表单
- 图表刻度改为朝内并同步导出图片
- 网格间隔严格使用编辑值并补充回归测试
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 {
alignDesignCurveToActualCurve,
buildPowerCurveModel,
getAxisMaximum,
getAxisTicks,
getDrawableActualCurve,
hasDrawablePowerCurveData,
} from '../utils/powerCurveModel';
@@ -159,8 +161,8 @@ const DEFAULT_CHART_OPTIONS = {
show_legend: true,
show_filtered: false,
scatter_size: 2,
x_tick_interval: '',
y_tick_interval: '',
x_tick_interval: '1',
y_tick_interval: '200',
scatter_color: '#2563eb',
scatter_opacity: 42,
show_scatter: true,
@@ -199,6 +201,12 @@ function normalizeChartOptions(options = {}) {
...DEFAULT_CHART_OPTIONS,
...currentOptions,
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();
}
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) {
if (!Number.isFinite(value)) return '';
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.lineWidth = 1;
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;
for (const xValue of getAxisTicks(xMin, xMax, options.x_tick_interval)) {
context.textAlign = 'center';
@@ -1353,7 +1345,7 @@ function PowerCurveChart({
label: activeChartOptions.x_axis_label,
stroke: activeChartOptions.text_color,
size: xAxisSize,
gap: 8,
gap: 18,
labelSize: axisLabelSize,
labelGap: 10,
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),
values: (u, splits) => splits.map(formatTick),
grid: { show: activeChartOptions.show_grid, stroke: '#d8e0ea', width: 1 },
ticks: { show: true, stroke: '#94a3b8', width: 1, size: -8 },
},
{
label: activeChartOptions.y_axis_label,
stroke: activeChartOptions.text_color,
size: yAxisSize,
gap: 8,
gap: 18,
labelSize: axisLabelSize,
labelGap: 10,
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),
values: (u, splits) => splits.map(formatTick),
grid: { show: activeChartOptions.show_grid, stroke: '#d8e0ea', width: 1 },
ticks: { show: true, stroke: '#94a3b8', width: 1, size: -8 },
},
],
series: [
@@ -3000,23 +2994,23 @@ export default function HomePage({ activeSection = 'settings' }) {
<b>{chartOptions.scatter_size}</b>
</label>
<label>
<span>X 刻度</span>
<span>X 网格间隔</span>
<input
type="number"
min="0.001"
step="any"
placeholder="自动"
placeholder="请输入间隔"
value={chartOptions.x_tick_interval}
onChange={(event) => handleChartOptionChange('x_tick_interval', event.target.value)}
/>
</label>
<label>
<span>Y 刻度</span>
<span>Y 网格间隔</span>
<input
type="number"
min="0.001"
step="any"
placeholder="自动"
placeholder="请输入间隔"
value={chartOptions.y_tick_interval}
onChange={(event) => handleChartOptionChange('y_tick_interval', event.target.value)}
/>
+26 -24
View File
@@ -5,7 +5,7 @@
align-items: center;
justify-content: center;
height: 100vh;
background: #080812;
background: linear-gradient(145deg, #f8fbff 0%, #eef4ff 48%, #f5f3ff 100%);
overflow: hidden;
}
@@ -14,6 +14,8 @@
position: absolute;
inset: 0;
z-index: 0;
opacity: 0.46;
filter: invert(1) hue-rotate(180deg) saturate(0.8);
}
/* ─── 中心径向辉光 ─── */
@@ -23,10 +25,10 @@
z-index: 1;
background:
radial-gradient(ellipse 60% 50% at 50% 50%,
rgba(99, 102, 241, 0.08) 0%,
rgba(99, 102, 241, 0.12) 0%,
transparent 70%),
radial-gradient(ellipse 40% 30% at 50% 45%,
rgba(139, 92, 246, 0.06) 0%,
rgba(139, 92, 246, 0.08) 0%,
transparent 60%);
pointer-events: none;
}
@@ -44,7 +46,7 @@
position: absolute;
bottom: -10px;
border-radius: 50%;
background: #818cf8;
background: #6366f1;
filter: blur(1px);
animation: floatUp linear infinite;
}
@@ -71,15 +73,15 @@
position: relative;
z-index: 10;
width: 400px;
background: rgba(18, 18, 30, 0.75);
border: 1px solid rgba(99, 102, 241, 0.15);
background: rgba(255, 255, 255, 0.86);
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 16px;
padding: 44px 36px 32px;
backdrop-filter: blur(20px) saturate(1.4);
backdrop-filter: blur(22px) saturate(1.2);
box-shadow:
0 0 60px rgba(99, 102, 241, 0.08),
0 0 120px rgba(139, 92, 246, 0.04),
0 24px 48px rgba(0, 0, 0, 0.5);
0 0 120px rgba(139, 92, 246, 0.05),
0 24px 60px rgba(51, 65, 85, 0.16);
animation: cardEnter 0.8s ease-out;
}
@@ -139,7 +141,7 @@
font-weight: 700;
text-align: center;
margin: 0 0 6px;
background: linear-gradient(135deg, #e0e7ff, #c7d2fe, #a5b4fc);
background: linear-gradient(135deg, #1e3a8a, #4f46e5, #7c3aed);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@@ -148,7 +150,7 @@
.subtitle {
font-size: 16px;
color: #6b7280;
color: #64748b;
text-align: center;
margin: 16px 0 22px;
letter-spacing: 6px;
@@ -162,7 +164,7 @@
.label {
display: block;
font-size: 15px;
color: #8b8fa3;
color: #475569;
margin-bottom: 8px;
letter-spacing: 0.5px;
}
@@ -170,10 +172,10 @@
.input {
width: 100%;
padding: 11px 14px;
background: rgba(20, 20, 36, 0.8);
border: 1px solid rgba(99, 102, 241, 0.12);
background: rgba(248, 250, 252, 0.92);
border: 1px solid #cbd5e1;
border-radius: 8px;
color: #d1d5db;
color: #0f172a;
font-size: 17px;
outline: none;
transition: all 0.2s ease;
@@ -181,13 +183,13 @@
}
.input:focus {
border-color: rgba(99, 102, 241, 0.5);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.08);
background: rgba(20, 20, 36, 1);
border-color: #818cf8;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12);
background: #ffffff;
}
.input::placeholder {
color: #4a4e5a;
color: #94a3b8;
}
.inputWrap {
@@ -207,7 +209,7 @@
border: none;
padding: 4px;
cursor: pointer;
color: #4a4e5a;
color: #64748b;
display: flex;
align-items: center;
justify-content: center;
@@ -221,12 +223,12 @@
/* ─── 错误提示 ─── */
.error {
font-size: 13px;
color: #f87171;
color: #b91c1c;
margin-bottom: 16px;
text-align: center;
padding: 8px 12px;
background: rgba(239, 68, 68, 0.06);
border: 1px solid rgba(239, 68, 68, 0.15);
background: #fff1f2;
border: 1px solid #fecaca;
border-radius: 6px;
}
@@ -298,6 +300,6 @@
.footerText {
font-size: 13px;
color: #4a4e5a;
color: #64748b;
letter-spacing: 1px;
}
@@ -4,6 +4,30 @@ function validNumber(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 = []) {
const grouped = new Map();
for (const point of points) {
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildActualCurveFromDesign,
getAxisTicks,
getDrawableActualCurve,
hasDrawablePowerCurveData,
} 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', () => {
assert.equal(hasDrawablePowerCurveData({
actualCurve: [],