feature:add plot panel x,y axis
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface HudPacket {
|
||||
|
||||
export interface HudSummary {
|
||||
label: string;
|
||||
xValues?: number[];
|
||||
points: number[];
|
||||
latest: number | null;
|
||||
min: number | null;
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user