feature:add plot panel x,y axis

This commit is contained in:
lennlouisgeek
2026-04-01 02:32:23 +08:00
parent eec9927ae6
commit e904c748aa
5 changed files with 203 additions and 26 deletions

View File

View File

@@ -250,7 +250,13 @@
in:fly={{ x: -180, duration: 340, opacity: 0.08, easing: cubicOut }} in:fly={{ x: -180, duration: 340, opacity: 0.08, easing: cubicOut }}
out:fly={{ x: -180, duration: 280, opacity: 0.06, easing: cubicIn }} out:fly={{ x: -180, duration: 280, opacity: 0.06, easing: cubicIn }}
> >
<SummaryCurve {summary} side="left" panelIndex={leftPanels.length} /> <SummaryCurve
{summary}
xValues={summary.xValues ?? null}
yValues={summary.points}
side="left"
panelIndex={leftPanels.length}
/>
</div> </div>
{/if} {/if}
</div> </div>
@@ -275,7 +281,13 @@
in:fly={{ x: 180, duration: 340, opacity: 0.08, easing: cubicOut }} in:fly={{ x: 180, duration: 340, opacity: 0.08, easing: cubicOut }}
out:fly={{ x: 180, duration: 280, opacity: 0.06, easing: cubicIn }} out:fly={{ x: 180, duration: 280, opacity: 0.06, easing: cubicIn }}
> >
<SummaryCurve {summary} side="right" panelIndex={rightPanels.length} /> <SummaryCurve
{summary}
xValues={summary.xValues ?? null}
yValues={summary.points}
side="right"
panelIndex={rightPanels.length}
/>
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -4,16 +4,31 @@
export let summary: HudSummary; export let summary: HudSummary;
export let side: "left" | "right" = "right"; export let side: "left" | "right" = "right";
export let panelIndex = 0; export let panelIndex = 0;
export let xValues: number[] | null = null;
export let yValues: number[] | null = null;
const viewportWidth = 100; const viewportWidth = 100;
const viewportHeight = 36; const viewportHeight = 36;
const horizontalInset = 2;
const verticalInset = 2; const verticalInset = 2;
interface CurveSample {
x: number;
y: number;
}
interface PlotPoint { interface PlotPoint {
x: number; x: number;
y: number; y: number;
} }
interface AxisTick {
value: number;
label: string;
plotX: number;
plotY: number;
}
function clamp(value: number, min: number, max: number): number { function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value)); return Math.min(max, Math.max(min, value));
} }
@@ -26,46 +41,133 @@
return value.toFixed(1); return value.toFixed(1);
} }
function resolveBounds(points: number[]): { min: number; max: number } { function formatAxisValue(value: number, axis: "x" | "y"): string {
if (points.length === 0) { if (!Number.isFinite(value)) {
return "--";
}
if (axis === "x") {
return String(Math.round(value));
}
if (Math.abs(value) >= 1000) {
const compact = Math.round((value / 1000) * 10) / 10;
return Number.isInteger(compact) ? `${compact.toFixed(0)}k` : `${compact.toFixed(1)}k`;
}
return Math.abs(value) >= 100 ? Math.round(value).toString() : value.toFixed(1);
}
function resolveDataBounds(values: number[]): { min: number; max: number } {
if (values.length === 0) {
return { min: 0, max: 1 }; return { min: 0, max: 1 };
} }
const min = Math.min(...points); return {
const max = Math.max(...points); min: Math.min(...values),
max: Math.max(...values)
};
}
function resolveBounds(values: number[]): { min: number; max: number } {
if (values.length === 0) {
return { min: 0, max: 1 };
}
const min = Math.min(...values);
const max = Math.max(...values);
if (Math.abs(max - min) < 0.001) { if (Math.abs(max - min) < 0.001) {
return { min: min - 1, max: max + 1 }; const padding = Math.max(Math.abs(max) * 0.05, 1);
return { min: min - padding, max: max + padding };
} }
return { min, max }; return { min, max };
} }
function convertPoints(rawPoints: number[]): PlotPoint[] { function mapXToViewport(value: number, bounds: { min: number; max: number }): number {
if (rawPoints.length === 0) { const span = bounds.max - bounds.min;
const chartWidth = viewportWidth - horizontalInset * 2;
const ratio = span <= 0 ? 0.5 : (value - bounds.min) / span;
const mappedX = horizontalInset + ratio * chartWidth;
return Math.round(clamp(mappedX, horizontalInset, viewportWidth - horizontalInset) * 100) / 100;
}
function mapYToViewport(value: number, bounds: { min: number; max: number }): number {
const span = bounds.max - bounds.min;
const chartHeight = viewportHeight - verticalInset * 2;
const ratio = span <= 0 ? 0.5 : (value - bounds.min) / span;
const mappedY = viewportHeight - verticalInset - ratio * chartHeight;
return Math.round(clamp(mappedY, verticalInset, viewportHeight - verticalInset) * 100) / 100;
}
function buildSamples(rawYValues: number[], rawXValues: number[]): CurveSample[] {
if (!rawYValues.length) {
return []; return [];
} }
if (rawPoints.length === 1) { return rawYValues.map((rawY, index) => {
const x = rawXValues[index];
const y = Number.isFinite(rawY) ? Number(rawY) : 0;
const resolvedX = Number.isFinite(x) ? Number(x) : index + 1;
return { x: resolvedX, y };
});
}
function convertPoints(
samples: CurveSample[],
xBounds: { min: number; max: number },
yBounds: { min: number; max: number }
): PlotPoint[] {
if (samples.length === 0) {
return [];
}
if (samples.length === 1) {
return [{ x: viewportWidth / 2, y: viewportHeight / 2 }]; return [{ x: viewportWidth / 2, y: viewportHeight / 2 }];
} }
const { min, max } = resolveBounds(rawPoints); return samples.map((sample) => {
const span = max - min;
const chartHeight = viewportHeight - verticalInset * 2;
const stepX = viewportWidth / (rawPoints.length - 1);
return rawPoints.map((point, index) => {
const ratio = span <= 0 ? 0.5 : (point - min) / span;
const y = viewportHeight - verticalInset - ratio * chartHeight;
return { return {
x: Math.round(index * stepX * 100) / 100, x: mapXToViewport(sample.x, xBounds),
y: Math.round(clamp(y, verticalInset, viewportHeight - verticalInset) * 100) / 100 y: mapYToViewport(sample.y, yBounds)
}; };
}); });
} }
function buildYAxisTicks(
yScaleBounds: { min: number; max: number },
yDataBounds: { min: number; max: number }
): AxisTick[] {
const hasRange = Math.abs(yDataBounds.max - yDataBounds.min) >= 0.001;
const tickValues = hasRange
? [yDataBounds.max, (yDataBounds.max + yDataBounds.min) / 2, yDataBounds.min]
: [yScaleBounds.max, (yScaleBounds.max + yScaleBounds.min) / 2, yScaleBounds.min];
return tickValues.map((value) => ({
value,
label: formatAxisValue(value, "y"),
plotX: horizontalInset,
plotY: mapYToViewport(value, yScaleBounds)
}));
}
function buildXAxisTicks(samples: CurveSample[], xScaleBounds: { min: number; max: number }): AxisTick[] {
if (!samples.length) {
return [];
}
const first = samples[0].x;
const middle = samples[Math.floor((samples.length - 1) / 2)].x;
const last = samples[samples.length - 1].x;
const tickValues = [first, middle, last];
return tickValues.map((value) => ({
value,
label: formatAxisValue(value, "x"),
plotX: mapXToViewport(value, xScaleBounds),
plotY: viewportHeight - 1.2
}));
}
function createLinePath(points: PlotPoint[]): string { function createLinePath(points: PlotPoint[]): string {
if (points.length === 0) { if (points.length === 0) {
return ""; return "";
@@ -86,14 +188,23 @@
return `${linePath} L ${lastPoint.x} ${viewportHeight} L ${firstPoint.x} ${viewportHeight} Z`; return `${linePath} L ${lastPoint.x} ${viewportHeight} L ${firstPoint.x} ${viewportHeight} Z`;
} }
$: plotPoints = convertPoints(summary.points); $: sourceYValues = yValues && yValues.length ? yValues : summary.points;
$: sourceXValues = xValues && xValues.length ? xValues : summary.xValues ?? [];
$: samples = buildSamples(sourceYValues, sourceXValues);
$: sampleCount = samples.length;
$: xScaleBounds = resolveBounds(samples.map((sample) => sample.x));
$: yScaleBounds = resolveBounds(samples.map((sample) => sample.y));
$: xDataBounds = resolveDataBounds(samples.map((sample) => sample.x));
$: yDataBounds = resolveDataBounds(samples.map((sample) => sample.y));
$: plotPoints = convertPoints(samples, xScaleBounds, yScaleBounds);
$: linePath = createLinePath(plotPoints); $: linePath = createLinePath(plotPoints);
$: areaPath = createAreaPath(plotPoints); $: areaPath = createAreaPath(plotPoints);
$: lastPoint = plotPoints.length > 0 ? plotPoints[plotPoints.length - 1] : null; $: lastPoint = plotPoints.length > 0 ? plotPoints[plotPoints.length - 1] : null;
$: yAxisTicks = sampleCount > 0 ? buildYAxisTicks(yScaleBounds, yDataBounds) : [];
$: xAxisTicks = sampleCount > 0 ? buildXAxisTicks(samples, xScaleBounds) : [];
$: latestValue = formatValue(summary.latest); $: latestValue = formatValue(summary.latest);
$: minValue = formatValue(summary.min); $: minValue = formatValue(summary.min);
$: maxValue = formatValue(summary.max); $: maxValue = formatValue(summary.max);
$: sampleCount = summary.points.length;
</script> </script>
<article <article
@@ -141,6 +252,25 @@
{#if lastPoint} {#if lastPoint}
<circle cx={lastPoint.x} cy={lastPoint.y} r="1.7" class="summary-dot"></circle> <circle cx={lastPoint.x} cy={lastPoint.y} r="1.7" class="summary-dot"></circle>
{/if} {/if}
<g class="axis-labels" aria-hidden="true">
{#each yAxisTicks as tick, index (`y-${index}`)}
<text class="axis-label y-axis-label" x={tick.plotX + 0.8} y={tick.plotY - 0.35} text-anchor="start">
{tick.label}
</text>
{/each}
{#each xAxisTicks as tick, index (`x-${index}`)}
<text
class="axis-label x-axis-label"
x={tick.plotX}
y={tick.plotY}
text-anchor={index === 0 ? "start" : index === xAxisTicks.length - 1 ? "end" : "middle"}
>
{tick.label}
</text>
{/each}
</g>
</svg> </svg>
{#if sampleCount === 0} {#if sampleCount === 0}
@@ -312,6 +442,24 @@
filter: drop-shadow(0 0 6px rgb(133 255 68 / 0.3)); filter: drop-shadow(0 0 6px rgb(133 255 68 / 0.3));
} }
.axis-label {
fill: rgb(176 204 222 / 0.88);
font-size: 2.8px;
font-weight: 500;
letter-spacing: 0.02em;
text-shadow:
0 1px 0 rgb(0 0 0 / 0.46),
0 0 4px rgb(0 0 0 / 0.3);
}
.y-axis-label {
fill: rgb(162 198 220 / 0.84);
}
.x-axis-label {
fill: rgb(162 198 220 / 0.9);
}
.empty-state { .empty-state {
position: absolute; position: absolute;
inset: 0; inset: 0;

View File

@@ -48,6 +48,7 @@ export interface HudPacket {
export interface HudSummary { export interface HudSummary {
label: string; label: string;
xValues?: number[];
points: number[]; points: number[];
latest: number | null; latest: number | null;
min: number | null; min: number | null;

View File

@@ -398,10 +398,12 @@
const safeIndex = clamp(index, 0, replayFrames.length - 1); const safeIndex = clamp(index, 0, replayFrames.length - 1);
const startIndex = Math.max(0, safeIndex - summaryPointsPerSeries + 1); const startIndex = Math.max(0, safeIndex - summaryPointsPerSeries + 1);
const points: number[] = []; const points: number[] = [];
const frameIds: number[] = [];
for (let cursor = startIndex; cursor <= safeIndex; cursor += 1) { for (let cursor = startIndex; cursor <= safeIndex; cursor += 1) {
points.push(replayFrameTotal(replayFrames[cursor])); points.push(replayFrameTotal(replayFrames[cursor]));
frameIds.push(cursor + 1);
} }
return buildSummary(points); return buildSummary(points, frameIds);
} }
function applyReplayFrame(index: number): void { function applyReplayFrame(index: number): void {
@@ -573,6 +575,7 @@
function buildEmptySummary(): HudSummary { function buildEmptySummary(): HudSummary {
return { return {
label: "TOTAL", label: "TOTAL",
xValues: [],
points: [], points: [],
latest: null, latest: null,
min: null, min: null,
@@ -580,13 +583,19 @@
}; };
} }
function buildSummary(points: number[]): HudSummary { function buildSummary(points: number[], xValues: number[] = []): HudSummary {
if (points.length === 0) { if (points.length === 0) {
return buildEmptySummary(); return buildEmptySummary();
} }
const resolvedXValues = points.map((_, index) => {
const x = xValues[index];
return Number.isFinite(x) ? Number(x) : index + 1;
});
return { return {
label: "TOTAL", label: "TOTAL",
xValues: resolvedXValues,
points, points,
latest: points[points.length - 1], latest: points[points.length - 1],
min: Math.min(...points), min: Math.min(...points),
@@ -611,13 +620,20 @@
? summaryValue.points[summaryValue.points.length - 1] ? summaryValue.points[summaryValue.points.length - 1]
: randomBetween(280, 1600); : randomBetween(280, 1600);
const next = Math.round(clamp(previous + randomBetween(-160, 160), 120, 2400) * 10) / 10; const next = Math.round(clamp(previous + randomBetween(-160, 160), 120, 2400) * 10) / 10;
const previousXValues =
summaryValue.xValues && summaryValue.xValues.length === summaryValue.points.length
? summaryValue.xValues
: summaryValue.points.map((_, index) => index + 1);
const points = const points =
summaryValue.points.length >= summaryPointsPerSeries summaryValue.points.length >= summaryPointsPerSeries
? summaryValue.points.slice(1) ? summaryValue.points.slice(1)
: summaryValue.points.slice(); : summaryValue.points.slice();
const xValues =
previousXValues.length >= summaryPointsPerSeries ? previousXValues.slice(1) : previousXValues.slice();
points.push(next); points.push(next);
return buildSummary(points); xValues.push((xValues[xValues.length - 1] ?? 0) + 1);
return buildSummary(points, xValues);
} }
function buildInactivePanels(): HudSignalPanel[] { function buildInactivePanels(): HudSignalPanel[] {