样式: 优化登录页与功率曲线坐标轴
- 登录页调整为浅色动态背景和浅色表单 - 图表刻度改为朝内并同步导出图片 - 网格间隔严格使用编辑值并补充回归测试
This commit is contained in:
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user