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