Skip to content

Commit e49c478

Browse files
Codex/limit time axis labels (#130)
* Limit Mermaid time-axis labels for long workflows * test * checkout忘れていた * Fix format (#131) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * スペースに変更 * Fix format (#132) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * ゼロ幅スペースで頑張る * Fix format (#133) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * 間隔調整 * Fix format (#134) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * 間引く * Fix format (#135) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * sleeptestを削除 --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 975605e commit e49c478

5 files changed

Lines changed: 234 additions & 8 deletions

File tree

dist/post/index.bundle.js

Lines changed: 59 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/post/index.bundle.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/post/renderer.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from "bun:test";
22
import { Renderer } from "./renderer";
3+
import { TimeLabelFormatter } from "./timeLabels";
34
import type { z } from "zod";
45
import type { legendsSchema } from "./lib";
56

@@ -492,4 +493,81 @@ describe("Renderer", () => {
492493
expect(result).toContain("<summary>Chart</summary>");
493494
expect(result).toContain("</details>");
494495
});
496+
497+
it("should limit visible time labels when times are long", () => {
498+
const renderer: Renderer = new Renderer();
499+
const startTime: number = Date.parse("2024-01-01T00:00:00Z");
500+
const samples: number = 120;
501+
const times: Date[] = Array.from(
502+
{ length: samples },
503+
(_, index: number): Date => new Date(startTime + index * 1000),
504+
);
505+
const firstLabel: string = times[0].toLocaleTimeString("en-GB", {
506+
hour12: false,
507+
});
508+
const lastLabel: string = times[times.length - 1].toLocaleTimeString(
509+
"en-GB",
510+
{ hour12: false },
511+
);
512+
513+
const result: string = renderer.render(
514+
[
515+
{
516+
title: "Long Timeline",
517+
legends: [{ color: "Gray", name: "Metric" }],
518+
data: [
519+
{
520+
stepName: undefined,
521+
stackedBarData: [
522+
Array.from(
523+
{ length: times.length },
524+
(_, index: number): number => index,
525+
),
526+
],
527+
times,
528+
yAxis: {
529+
title: "Units",
530+
},
531+
},
532+
],
533+
},
534+
],
535+
testMetricsID,
536+
);
537+
538+
const match: RegExpMatchArray | null = result.match(
539+
/x-axis "Time" (\[[^\]]+\])/,
540+
);
541+
expect(match).not.toBeNull();
542+
const labels: string[] = JSON.parse(match![1]);
543+
544+
const hasVisibleCharacters = (label: string): boolean =>
545+
/[0-9:]/.test(label);
546+
547+
expect(labels.length).toBe(times.length);
548+
expect(labels[0]).toBe(firstLabel);
549+
expect(labels[labels.length - 1]).toBe(lastLabel);
550+
const visibleIndices: number[] = labels
551+
.map((label: string, index: number): number =>
552+
hasVisibleCharacters(label) ? index : -1,
553+
)
554+
.filter((index: number): index is number => index >= 0);
555+
expect(visibleIndices[0]).toBe(0);
556+
expect(visibleIndices[visibleIndices.length - 1]).toBe(labels.length - 1);
557+
558+
const labelStep: number = TimeLabelFormatter.calculateLabelStep(
559+
labels.length,
560+
);
561+
for (let i = 1; i < visibleIndices.length; i += 1) {
562+
const current: number = visibleIndices[i];
563+
const previous: number = visibleIndices[i - 1];
564+
expect(current - previous).toBeGreaterThanOrEqual(labelStep);
565+
}
566+
567+
const hiddenLabels: string[] = labels.filter(
568+
(label: string): boolean => !hasVisibleCharacters(label),
569+
);
570+
expect(hiddenLabels.length).toBe(labels.length - visibleIndices.length);
571+
expect(new Set(hiddenLabels).size).toBe(hiddenLabels.length);
572+
});
495573
});

src/post/renderer.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
stackedBarDataSchema,
99
timesSchema,
1010
} from "./lib";
11+
import { TimeLabelFormatter } from "./timeLabels";
1112

1213
export class Renderer {
1314
render(
@@ -52,11 +53,7 @@ ${charts}`;
5253
}
5354

5455
private formatTimes(times: z.TypeOf<typeof timesSchema>): string {
55-
return JSON.stringify(
56-
times.map((d: Date): string =>
57-
d.toLocaleTimeString("en-GB", { hour12: false }),
58-
),
59-
);
56+
return JSON.stringify(TimeLabelFormatter.format(times));
6057
}
6158

6259
private formatYAxisRange(range?: string): string {

src/post/timeLabels.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Canvas metrics are measured from the GitHub Actions summary Mermaid output.
2+
const CHART_WIDTH_PX: number = 1161;
3+
const TICK_WIDTH_PX: number = 5;
4+
const LABEL_WIDTH_PX: number = 107;
5+
const REQUIRED_GAP_PX: number = LABEL_WIDTH_PX - TICK_WIDTH_PX;
6+
const ZERO_WIDTH_ZERO: string = "\u200b";
7+
const ZERO_WIDTH_ONE: string = "\u200c";
8+
const ZERO_WIDTH_SENTINEL: string = "\u200d";
9+
10+
type TimePoints = ReadonlyArray<Date>;
11+
12+
export class TimeLabelFormatter {
13+
public static calculateLabelStep(count: number): number {
14+
if (count <= 2) {
15+
return 1;
16+
}
17+
18+
const totalGapWidth: number = CHART_WIDTH_PX - TICK_WIDTH_PX * count;
19+
if (totalGapWidth <= 0) {
20+
return count;
21+
}
22+
23+
const numerator: number = REQUIRED_GAP_PX * (count - 1);
24+
return Math.max(1, Math.ceil(numerator / totalGapWidth));
25+
}
26+
27+
public static format(times: TimePoints): string[] {
28+
if (times.length === 0) {
29+
return [];
30+
}
31+
32+
const formattedTimes: string[] = times.map((d: Date): string =>
33+
d.toLocaleTimeString("en-GB", { hour12: false }),
34+
);
35+
36+
if (formattedTimes.length <= 2) {
37+
return formattedTimes;
38+
}
39+
40+
const labelStep: number = TimeLabelFormatter.calculateLabelStep(
41+
formattedTimes.length,
42+
);
43+
if (labelStep <= 1) {
44+
return formattedTimes;
45+
}
46+
47+
const lastIndex: number = formattedTimes.length - 1;
48+
const totalInteriorPositions: number = Math.max(
49+
formattedTimes.length - 2,
50+
0,
51+
);
52+
const estimatedInteriorLabels: number = Math.max(
53+
Math.floor(lastIndex / labelStep) - 1,
54+
0,
55+
);
56+
const usableInteriorLabels: number = Math.min(
57+
totalInteriorPositions,
58+
estimatedInteriorLabels,
59+
);
60+
61+
const visibleLabelIndices: Set<number> = new Set<number>([0, lastIndex]);
62+
if (usableInteriorLabels > 0) {
63+
const spacing: number =
64+
totalInteriorPositions / (usableInteriorLabels + 1);
65+
for (let slot: number = 1; slot <= usableInteriorLabels; slot += 1) {
66+
const targetIndex: number = 1 + Math.round(slot * spacing);
67+
let clamped: number = Math.min(lastIndex - 1, Math.max(1, targetIndex));
68+
while (visibleLabelIndices.has(clamped) && clamped < lastIndex - 1) {
69+
clamped += 1;
70+
}
71+
visibleLabelIndices.add(clamped);
72+
}
73+
}
74+
75+
return formattedTimes.map((label: string, index: number): string =>
76+
visibleLabelIndices.has(index)
77+
? label
78+
: TimeLabelFormatter.encodeHiddenLabel(index),
79+
);
80+
}
81+
82+
private static encodeHiddenLabel(index: number): string {
83+
const binary: string = index.toString(2);
84+
return (
85+
ZERO_WIDTH_SENTINEL +
86+
binary
87+
.split("")
88+
.map((digit: string): string =>
89+
digit === "0" ? ZERO_WIDTH_ZERO : ZERO_WIDTH_ONE,
90+
)
91+
.join("")
92+
);
93+
}
94+
}

0 commit comments

Comments
 (0)