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

@@ -250,7 +250,13 @@
in:fly={{ x: -180, duration: 340, opacity: 0.08, easing: cubicOut }}
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>
{/if}
</div>
@@ -275,7 +281,13 @@
in:fly={{ x: 180, duration: 340, opacity: 0.08, easing: cubicOut }}
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>
{/if}
</div>

View File

@@ -4,16 +4,31 @@
export let summary: HudSummary;
export let side: "left" | "right" = "right";
export let panelIndex = 0;
export let xValues: number[] | null = null;
export let yValues: number[] | null = null;
const viewportWidth = 100;
const viewportHeight = 36;
const horizontalInset = 2;
const verticalInset = 2;
interface CurveSample {
x: number;
y: number;
}
interface PlotPoint {
x: number;
y: number;
}
interface AxisTick {
value: number;
label: string;
plotX: number;
plotY: number;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
@@ -26,46 +41,133 @@
return value.toFixed(1);
}
function resolveBounds(points: number[]): { min: number; max: number } {
if (points.length === 0) {
function formatAxisValue(value: number, axis: "x" | "y"): string {
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 };
}
const min = Math.min(...points);
const max = Math.max(...points);
return {
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) {
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 };
}
function convertPoints(rawPoints: number[]): PlotPoint[] {
if (rawPoints.length === 0) {
function mapXToViewport(value: number, bounds: { min: number; max: number }): number {
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 [];
}
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 }];
}
const { min, max } = resolveBounds(rawPoints);
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 samples.map((sample) => {
return {
x: Math.round(index * stepX * 100) / 100,
y: Math.round(clamp(y, verticalInset, viewportHeight - verticalInset) * 100) / 100
x: mapXToViewport(sample.x, xBounds),
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 {
if (points.length === 0) {
return "";
@@ -86,14 +188,23 @@
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);
$: areaPath = createAreaPath(plotPoints);
$: 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);
$: minValue = formatValue(summary.min);
$: maxValue = formatValue(summary.max);
$: sampleCount = summary.points.length;
</script>
<article
@@ -141,6 +252,25 @@
{#if lastPoint}
<circle cx={lastPoint.x} cy={lastPoint.y} r="1.7" class="summary-dot"></circle>
{/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>
{#if sampleCount === 0}
@@ -312,6 +442,24 @@
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 {
position: absolute;
inset: 0;

View File

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

View File

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