@jorgerdz/timeview 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +263 -0
  4. package/dist/cli/timeview.js +6710 -0
  5. package/dist/timeview.cjs +1 -0
  6. package/dist/timeview.js +5667 -0
  7. package/dist/tokens.css +67 -0
  8. package/dist/types/timeview/BandedTimeline.d.ts +11 -0
  9. package/dist/types/timeview/BandedTimeline.d.ts.map +1 -0
  10. package/dist/types/timeview/DensityHeatmap.d.ts +11 -0
  11. package/dist/types/timeview/DensityHeatmap.d.ts.map +1 -0
  12. package/dist/types/timeview/LaneCalendar.d.ts +11 -0
  13. package/dist/types/timeview/LaneCalendar.d.ts.map +1 -0
  14. package/dist/types/timeview/MetricTimeline.d.ts +8 -0
  15. package/dist/types/timeview/MetricTimeline.d.ts.map +1 -0
  16. package/dist/types/timeview/SpanMatrix.d.ts +8 -0
  17. package/dist/types/timeview/SpanMatrix.d.ts.map +1 -0
  18. package/dist/types/timeview/config.d.ts +22 -0
  19. package/dist/types/timeview/config.d.ts.map +1 -0
  20. package/dist/types/timeview/core/aggregate.d.ts +113 -0
  21. package/dist/types/timeview/core/aggregate.d.ts.map +1 -0
  22. package/dist/types/timeview/core/calendar.d.ts +27 -0
  23. package/dist/types/timeview/core/calendar.d.ts.map +1 -0
  24. package/dist/types/timeview/core/intervals.d.ts +8 -0
  25. package/dist/types/timeview/core/intervals.d.ts.map +1 -0
  26. package/dist/types/timeview/core/labels.d.ts +5 -0
  27. package/dist/types/timeview/core/labels.d.ts.map +1 -0
  28. package/dist/types/timeview/core/metric.d.ts +58 -0
  29. package/dist/types/timeview/core/metric.d.ts.map +1 -0
  30. package/dist/types/timeview/core/time.d.ts +22 -0
  31. package/dist/types/timeview/core/time.d.ts.map +1 -0
  32. package/dist/types/timeview/dashboard.d.ts +17 -0
  33. package/dist/types/timeview/dashboard.d.ts.map +1 -0
  34. package/dist/types/timeview/data.d.ts +21 -0
  35. package/dist/types/timeview/data.d.ts.map +1 -0
  36. package/dist/types/timeview/export.d.ts +14 -0
  37. package/dist/types/timeview/export.d.ts.map +1 -0
  38. package/dist/types/timeview/index.d.ts +28 -0
  39. package/dist/types/timeview/index.d.ts.map +1 -0
  40. package/dist/types/timeview/registry.d.ts +285 -0
  41. package/dist/types/timeview/registry.d.ts.map +1 -0
  42. package/dist/types/timeview/shared/Caption.d.ts +9 -0
  43. package/dist/types/timeview/shared/Caption.d.ts.map +1 -0
  44. package/dist/types/timeview/shared/EmptyState.d.ts +16 -0
  45. package/dist/types/timeview/shared/EmptyState.d.ts.map +1 -0
  46. package/dist/types/timeview/shared/Legend.d.ts +10 -0
  47. package/dist/types/timeview/shared/Legend.d.ts.map +1 -0
  48. package/dist/types/timeview/shared/Tooltip.d.ts +15 -0
  49. package/dist/types/timeview/shared/Tooltip.d.ts.map +1 -0
  50. package/dist/types/timeview/shared/useMeasuredWidth.d.ts +2 -0
  51. package/dist/types/timeview/shared/useMeasuredWidth.d.ts.map +1 -0
  52. package/dist/types/timeview/types.d.ts +158 -0
  53. package/dist/types/timeview/types.d.ts.map +1 -0
  54. package/docs/AGENT-USAGE.md +93 -0
  55. package/docs/COMPATIBILITY.md +134 -0
  56. package/docs/STUDIO.md +41 -0
  57. package/examples/README.md +21 -0
  58. package/examples/configs/bandedTimeline.json +31 -0
  59. package/examples/configs/densityHeatmap.json +33 -0
  60. package/examples/configs/laneCalendar.json +31 -0
  61. package/examples/configs/metricTimeline.json +51 -0
  62. package/examples/configs/spanMatrix.json +31 -0
  63. package/package.json +94 -0
  64. package/render.html +12 -0
  65. package/src/render.tsx +67 -0
  66. package/src/styles/tokens.css +67 -0
  67. package/src/timeview/BandedTimeline.tsx +620 -0
  68. package/src/timeview/DensityHeatmap.tsx +513 -0
  69. package/src/timeview/LaneCalendar.tsx +496 -0
  70. package/src/timeview/MetricTimeline.tsx +993 -0
  71. package/src/timeview/SpanMatrix.tsx +721 -0
  72. package/src/timeview/config.ts +399 -0
  73. package/src/timeview/core/aggregate.ts +317 -0
  74. package/src/timeview/core/calendar.ts +81 -0
  75. package/src/timeview/core/intervals.ts +52 -0
  76. package/src/timeview/core/labels.ts +19 -0
  77. package/src/timeview/core/metric.ts +263 -0
  78. package/src/timeview/core/time.ts +103 -0
  79. package/src/timeview/dashboard.ts +80 -0
  80. package/src/timeview/data.ts +242 -0
  81. package/src/timeview/export.ts +48 -0
  82. package/src/timeview/index.ts +106 -0
  83. package/src/timeview/registry.ts +207 -0
  84. package/src/timeview/shared/Caption.tsx +40 -0
  85. package/src/timeview/shared/EmptyState.tsx +90 -0
  86. package/src/timeview/shared/Legend.tsx +67 -0
  87. package/src/timeview/shared/Tooltip.tsx +59 -0
  88. package/src/timeview/shared/useMeasuredWidth.ts +21 -0
  89. package/src/timeview/types.ts +159 -0
  90. package/vite.config.ts +11 -0
@@ -0,0 +1,399 @@
1
+ // config.ts — validation, normalization, and URL-safe config serialization.
2
+
3
+ import { TV_PALETTES } from "./data";
4
+ import { TV_VISUALIZER_BY_ID, type VisualizerId } from "./registry";
5
+ import type {
6
+ BandedTimelineSpec,
7
+ CaptionPosition,
8
+ Density,
9
+ DensityHeatmapSpec,
10
+ HeatmapGroupBy,
11
+ HeatmapMeasure,
12
+ HeatmapScaleMode,
13
+ LaneCalendarSpec,
14
+ LaneMode,
15
+ LegendPosition,
16
+ MetricDefaultDays,
17
+ MetricStateMode,
18
+ MetricTimelineSpec,
19
+ MetricYAxisMode,
20
+ OverlapMode,
21
+ SpanMatrixSpec,
22
+ TimeBucketMode,
23
+ TimeDataset,
24
+ TodayValue,
25
+ ViewSpec,
26
+ } from "./types";
27
+
28
+ export interface TimeviewConfigV1 {
29
+ v: 1;
30
+ visualizer: VisualizerId;
31
+ dataset: TimeDataset;
32
+ spec: ViewSpec;
33
+ palette: string[];
34
+ }
35
+
36
+ export interface ValidationResult<T> {
37
+ value: T | null;
38
+ errors: string[];
39
+ }
40
+
41
+ const DENSITY_OPTIONS = ["comfortable", "compact"] as const satisfies readonly Density[];
42
+ const LEGEND_OPTIONS = ["top", "bottom", "right", "off"] as const satisfies readonly LegendPosition[];
43
+ const CAPTION_OPTIONS = ["top", "bottom", "off"] as const satisfies readonly CaptionPosition[];
44
+ const OVERLAP_OPTIONS = ["lanes", "packed", "layered", "hatched"] as const satisfies readonly OverlapMode[];
45
+ const LANE_OPTIONS = ["packed", "category"] as const satisfies readonly LaneMode[];
46
+ const BUCKET_OPTIONS = ["day", "week"] as const satisfies readonly TimeBucketMode[];
47
+ const MEASURE_OPTIONS = ["count", "duration"] as const satisfies readonly HeatmapMeasure[];
48
+ const GROUP_OPTIONS = ["category", "none"] as const satisfies readonly HeatmapGroupBy[];
49
+ const SCALE_OPTIONS = ["category", "uniform"] as const satisfies readonly HeatmapScaleMode[];
50
+ const SPAN_ZOOM_MIN = 0.6;
51
+ const SPAN_ZOOM_MAX = 2.5;
52
+ const METRIC_STATE_OPTIONS = ["band", "tint", "off"] as const satisfies readonly MetricStateMode[];
53
+ const METRIC_Y_OPTIONS = ["auto", "full"] as const satisfies readonly MetricYAxisMode[];
54
+ const METRIC_DEFAULT_DAYS_OPTIONS = [30, 90, 180, 365] as const satisfies readonly MetricDefaultDays[];
55
+
56
+ function isRecord(value: unknown): value is Record<string, unknown> {
57
+ return !!value && typeof value === "object" && !Array.isArray(value);
58
+ }
59
+
60
+ function validDate(value: unknown): boolean {
61
+ return typeof value === "string" && !Number.isNaN(new Date(value).getTime());
62
+ }
63
+
64
+ function hasValue<T extends string>(options: readonly T[], value: unknown): value is T {
65
+ return typeof value === "string" && (options as readonly string[]).includes(value);
66
+ }
67
+
68
+ function hasNumberValue<T extends number>(options: readonly T[], value: unknown): value is T {
69
+ return typeof value === "number" && (options as readonly number[]).includes(value);
70
+ }
71
+
72
+ function readBool(value: unknown, fallback: boolean): boolean {
73
+ return typeof value === "boolean" ? value : fallback;
74
+ }
75
+
76
+ function readNumber(value: unknown, fallback: number): number {
77
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
78
+ }
79
+
80
+ function clone<T>(value: T): T {
81
+ return JSON.parse(JSON.stringify(value)) as T;
82
+ }
83
+
84
+ function pushInvalid(errors: string[], path: string, values: readonly string[]) {
85
+ errors.push(`${path} must be one of: ${values.join(", ")}.`);
86
+ }
87
+
88
+ export function validateTimeDataset(value: unknown): ValidationResult<TimeDataset> {
89
+ const errors: string[] = [];
90
+ if (!isRecord(value)) return { value: null, errors: ["Root must be a JSON object."] };
91
+
92
+ const data = value as Partial<TimeDataset>;
93
+ if (data.schemaVersion !== "timeview.dataset.v1") errors.push('schemaVersion must be "timeview.dataset.v1".');
94
+ if (typeof data.timezone !== "string" || !data.timezone.trim()) errors.push("timezone is required.");
95
+ if (!Array.isArray(data.labels)) errors.push("labels must be an array.");
96
+ if (!Array.isArray(data.events)) errors.push("events must be an array.");
97
+ if (!Array.isArray(data.intervals)) errors.push("intervals must be an array.");
98
+ if (errors.length) return { value: null, errors };
99
+
100
+ const ids = new Set<string>();
101
+ data.labels?.forEach((label, i) => {
102
+ if (!isRecord(label)) {
103
+ errors.push(`labels[${i}] must be an object.`);
104
+ return;
105
+ }
106
+ if (typeof label.id !== "string" || !label.id.trim()) errors.push(`labels[${i}].id is required.`);
107
+ if (typeof label.name !== "string" || !label.name.trim()) errors.push(`labels[${i}].name is required.`);
108
+ if (typeof label.id === "string") {
109
+ if (ids.has(label.id)) errors.push(`Duplicate label id "${label.id}".`);
110
+ ids.add(label.id);
111
+ }
112
+ });
113
+
114
+ data.events?.forEach((event, i) => {
115
+ if (!isRecord(event)) {
116
+ errors.push(`events[${i}] must be an object.`);
117
+ return;
118
+ }
119
+ if (typeof event.id !== "string" || !event.id.trim()) errors.push(`events[${i}].id is required.`);
120
+ if (typeof event.title !== "string" || !event.title.trim()) errors.push(`events[${i}].title is required.`);
121
+ if (!validDate(event.at)) errors.push(`events[${i}].at must be a valid date string.`);
122
+ if (event.labelIds != null && !Array.isArray(event.labelIds)) {
123
+ errors.push(`events[${i}].labelIds must be an array.`);
124
+ } else {
125
+ event.labelIds?.forEach((id) => {
126
+ if (typeof id !== "string" || !ids.has(id)) errors.push(`events[${i}] references unknown label "${String(id)}".`);
127
+ });
128
+ }
129
+ });
130
+
131
+ data.intervals?.forEach((interval, i) => {
132
+ if (!isRecord(interval)) {
133
+ errors.push(`intervals[${i}] must be an object.`);
134
+ return;
135
+ }
136
+ if (typeof interval.id !== "string" || !interval.id.trim()) errors.push(`intervals[${i}].id is required.`);
137
+ if (typeof interval.title !== "string" || !interval.title.trim()) errors.push(`intervals[${i}].title is required.`);
138
+ const range = interval.range;
139
+ if (!isRecord(range)) {
140
+ errors.push(`intervals[${i}].range is required.`);
141
+ return;
142
+ }
143
+ if (!validDate(range.start)) errors.push(`intervals[${i}].range.start must be a valid date string.`);
144
+ if (!validDate(range.end)) errors.push(`intervals[${i}].range.end must be a valid date string.`);
145
+ if (validDate(range.start) && validDate(range.end) && new Date(range.end as string).getTime() < new Date(range.start as string).getTime()) {
146
+ errors.push(`intervals[${i}].range.end must be after start.`);
147
+ }
148
+ if (interval.labelIds != null && !Array.isArray(interval.labelIds)) {
149
+ errors.push(`intervals[${i}].labelIds must be an array.`);
150
+ } else {
151
+ interval.labelIds?.forEach((id) => {
152
+ if (typeof id !== "string" || !ids.has(id)) errors.push(`intervals[${i}] references unknown label "${String(id)}".`);
153
+ });
154
+ }
155
+ });
156
+
157
+ if (data.series != null) {
158
+ if (!isRecord(data.series)) {
159
+ errors.push("series must be an object.");
160
+ } else {
161
+ if (typeof data.series.id !== "string" || !data.series.id.trim()) errors.push("series.id is required.");
162
+ if (typeof data.series.name !== "string" || !data.series.name.trim()) errors.push("series.name is required.");
163
+ if (data.series.unit != null && typeof data.series.unit !== "string") errors.push("series.unit must be a string.");
164
+ if (!Array.isArray(data.series.samples)) {
165
+ errors.push("series.samples must be an array.");
166
+ } else {
167
+ data.series.samples.forEach((sample, i) => {
168
+ if (!isRecord(sample)) {
169
+ errors.push(`series.samples[${i}] must be an object.`);
170
+ return;
171
+ }
172
+ if (!validDate(sample.at)) errors.push(`series.samples[${i}].at must be a valid date string.`);
173
+ if (typeof sample.value !== "number" || !Number.isFinite(sample.value)) errors.push(`series.samples[${i}].value must be a finite number.`);
174
+ });
175
+ }
176
+ if (data.series.target != null) {
177
+ if (!isRecord(data.series.target)) {
178
+ errors.push("series.target must be an object.");
179
+ } else if (typeof data.series.target.value !== "number" || !Number.isFinite(data.series.target.value)) {
180
+ errors.push("series.target.value must be a finite number.");
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ return { value: errors.length ? null : (value as unknown as TimeDataset), errors };
187
+ }
188
+
189
+ function normalizeCaption(defaultSpec: ViewSpec, input: Record<string, unknown>, errors: string[]) {
190
+ const fallback = defaultSpec.caption || { position: "bottom" as CaptionPosition };
191
+ if (!isRecord(input.caption)) return fallback;
192
+ const position = input.caption.enabled === false ? "off" : input.caption.position;
193
+ if (position != null && !hasValue(CAPTION_OPTIONS, position)) pushInvalid(errors, "caption.position", CAPTION_OPTIONS);
194
+ return {
195
+ enabled: input.caption.enabled !== false,
196
+ position: hasValue(CAPTION_OPTIONS, position) ? position : fallback.position,
197
+ text: typeof input.caption.text === "string" ? input.caption.text : fallback.text,
198
+ };
199
+ }
200
+
201
+ function normalizeLegend(defaultSpec: ViewSpec, input: Record<string, unknown>, errors: string[]) {
202
+ const fallback = defaultSpec.legend || { position: "bottom" as LegendPosition };
203
+ if (!isRecord(input.legend)) return fallback;
204
+ const position = input.legend.enabled === false ? "off" : input.legend.position;
205
+ if (position != null && !hasValue(LEGEND_OPTIONS, position)) pushInvalid(errors, "legend.position", LEGEND_OPTIONS);
206
+ return {
207
+ enabled: input.legend.enabled !== false,
208
+ position: hasValue(LEGEND_OPTIONS, position) ? position : fallback.position,
209
+ };
210
+ }
211
+
212
+ function normalizeEvents(defaultSpec: ViewSpec, input: Record<string, unknown>) {
213
+ const fallback = defaultSpec.events || {};
214
+ if (!isRecord(input.events)) return fallback;
215
+ return {
216
+ showPoints: readBool(input.events.showPoints, fallback.showPoints !== false),
217
+ showLabels: readBool(input.events.showLabels, fallback.showLabels !== false),
218
+ showMarkers: readBool(input.events.showMarkers, fallback.showMarkers !== false),
219
+ };
220
+ }
221
+
222
+ function common(defaultSpec: ViewSpec, input: Record<string, unknown>, errors: string[]) {
223
+ if (input.density != null && !hasValue(DENSITY_OPTIONS, input.density)) pushInvalid(errors, "density", DENSITY_OPTIONS);
224
+ return {
225
+ title: typeof input.title === "string" ? input.title : defaultSpec.title,
226
+ density: hasValue(DENSITY_OPTIONS, input.density) ? input.density : defaultSpec.density,
227
+ caption: normalizeCaption(defaultSpec, input, errors),
228
+ };
229
+ }
230
+
231
+ export function normalizeViewSpec(visualizer: VisualizerId, rawSpec: unknown): ValidationResult<ViewSpec> {
232
+ const entry = TV_VISUALIZER_BY_ID[visualizer];
233
+ const defaultSpec = entry.defaultSpec;
234
+ const input = isRecord(rawSpec) ? rawSpec : {};
235
+ const errors: string[] = [];
236
+
237
+ if (!isRecord(rawSpec)) errors.push("Spec root must be a JSON object.");
238
+ if (input.kind != null && input.kind !== visualizer) errors.push(`kind must be "${visualizer}" for ${entry.label}.`);
239
+
240
+ if (visualizer === "bandedTimeline") {
241
+ if (input.overlapMode != null && !hasValue(OVERLAP_OPTIONS, input.overlapMode)) pushInvalid(errors, "overlapMode", OVERLAP_OPTIONS);
242
+ const spec: BandedTimelineSpec = {
243
+ kind: "bandedTimeline",
244
+ ...common(defaultSpec, input, errors),
245
+ legend: normalizeLegend(defaultSpec, input, errors),
246
+ events: normalizeEvents(defaultSpec, input),
247
+ overlapMode: hasValue(OVERLAP_OPTIONS, input.overlapMode) ? input.overlapMode : (defaultSpec as BandedTimelineSpec).overlapMode,
248
+ };
249
+ return { value: spec, errors };
250
+ }
251
+
252
+ if (visualizer === "laneCalendar") {
253
+ if (input.laneMode != null && !hasValue(LANE_OPTIONS, input.laneMode)) pushInvalid(errors, "laneMode", LANE_OPTIONS);
254
+ if (input.today != null && input.today !== "auto" && !validDate(input.today)) errors.push('today must be "auto", an ISO date string, null, or omitted.');
255
+ const spec: LaneCalendarSpec = {
256
+ kind: "laneCalendar",
257
+ ...common(defaultSpec, input, errors),
258
+ legend: normalizeLegend(defaultSpec, input, errors),
259
+ events: normalizeEvents(defaultSpec, input),
260
+ laneMode: hasValue(LANE_OPTIONS, input.laneMode) ? input.laneMode : (defaultSpec as LaneCalendarSpec).laneMode,
261
+ today: input.today === "auto" || validDate(input.today) ? (input.today as TodayValue) : input.today === null ? null : undefined,
262
+ };
263
+ return { value: spec, errors };
264
+ }
265
+
266
+ if (visualizer === "spanMatrix") {
267
+ if (input.bucket != null && !hasValue(BUCKET_OPTIONS, input.bucket)) pushInvalid(errors, "bucket", BUCKET_OPTIONS);
268
+ if (input.groupBy != null && !hasValue(GROUP_OPTIONS, input.groupBy)) pushInvalid(errors, "groupBy", GROUP_OPTIONS);
269
+ if (input.today != null && !validDate(input.today)) errors.push("today must be an ISO date string, null, or omitted.");
270
+ if (
271
+ input.zoom != null &&
272
+ (typeof input.zoom !== "number" || !Number.isFinite(input.zoom) || input.zoom < SPAN_ZOOM_MIN || input.zoom > SPAN_ZOOM_MAX)
273
+ ) {
274
+ errors.push(`zoom must be a number from ${SPAN_ZOOM_MIN} to ${SPAN_ZOOM_MAX}.`);
275
+ }
276
+
277
+ const spec: SpanMatrixSpec = {
278
+ kind: "spanMatrix",
279
+ ...common(defaultSpec, input, errors),
280
+ legend: normalizeLegend(defaultSpec, input, errors),
281
+ events: normalizeEvents(defaultSpec, input),
282
+ bucket: hasValue(BUCKET_OPTIONS, input.bucket) ? input.bucket : (defaultSpec as SpanMatrixSpec).bucket,
283
+ groupBy: hasValue(GROUP_OPTIONS, input.groupBy) ? input.groupBy : (defaultSpec as SpanMatrixSpec).groupBy,
284
+ zoom: Math.min(SPAN_ZOOM_MAX, Math.max(SPAN_ZOOM_MIN, readNumber(input.zoom, (defaultSpec as SpanMatrixSpec).zoom || 1))),
285
+ showCounts: readBool(input.showCounts, (defaultSpec as SpanMatrixSpec).showCounts !== false),
286
+ today: validDate(input.today) ? (input.today as string) : input.today === null ? null : (defaultSpec as SpanMatrixSpec).today,
287
+ };
288
+ return { value: spec, errors };
289
+ }
290
+
291
+ if (visualizer === "metricTimeline") {
292
+ if (input.stateMode != null && !hasValue(METRIC_STATE_OPTIONS, input.stateMode)) pushInvalid(errors, "stateMode", METRIC_STATE_OPTIONS);
293
+ if (input.yAxis != null && !hasValue(METRIC_Y_OPTIONS, input.yAxis)) pushInvalid(errors, "yAxis", METRIC_Y_OPTIONS);
294
+ if (input.defaultDays != null && !hasNumberValue(METRIC_DEFAULT_DAYS_OPTIONS, input.defaultDays)) errors.push("defaultDays must be one of: 30, 90, 180, 365.");
295
+ if (input.today != null && !validDate(input.today)) errors.push("today must be an ISO date string, null, or omitted.");
296
+
297
+ const metricDefault = defaultSpec as MetricTimelineSpec;
298
+ const spec: MetricTimelineSpec = {
299
+ kind: "metricTimeline",
300
+ ...common(defaultSpec, input, errors),
301
+ legend: normalizeLegend(defaultSpec, input, errors),
302
+ events: normalizeEvents(defaultSpec, input),
303
+ stateMode: hasValue(METRIC_STATE_OPTIONS, input.stateMode) ? input.stateMode : metricDefault.stateMode,
304
+ yAxis: hasValue(METRIC_Y_OPTIONS, input.yAxis) ? input.yAxis : metricDefault.yAxis,
305
+ defaultDays: hasNumberValue(METRIC_DEFAULT_DAYS_OPTIONS, input.defaultDays) ? input.defaultDays : metricDefault.defaultDays,
306
+ today: validDate(input.today) ? (input.today as string) : input.today === null ? null : metricDefault.today,
307
+ showPoints: readBool(input.showPoints, metricDefault.showPoints !== false),
308
+ showValues: readBool(input.showValues, metricDefault.showValues === true),
309
+ showTarget: readBool(input.showTarget, metricDefault.showTarget !== false),
310
+ minimap: readBool(input.minimap, metricDefault.minimap !== false),
311
+ };
312
+ return { value: spec, errors };
313
+ }
314
+
315
+ if (input.bucket != null && !hasValue(BUCKET_OPTIONS, input.bucket)) pushInvalid(errors, "bucket", BUCKET_OPTIONS);
316
+ if (input.measure != null && !hasValue(MEASURE_OPTIONS, input.measure)) pushInvalid(errors, "measure", MEASURE_OPTIONS);
317
+ if (input.groupBy != null && !hasValue(GROUP_OPTIONS, input.groupBy)) pushInvalid(errors, "groupBy", GROUP_OPTIONS);
318
+ if (input.scaleMode != null && !hasValue(SCALE_OPTIONS, input.scaleMode)) pushInvalid(errors, "scaleMode", SCALE_OPTIONS);
319
+
320
+ const spec: DensityHeatmapSpec = {
321
+ kind: "densityHeatmap",
322
+ ...common(defaultSpec, input, errors),
323
+ bucket: hasValue(BUCKET_OPTIONS, input.bucket) ? input.bucket : (defaultSpec as DensityHeatmapSpec).bucket,
324
+ measure: hasValue(MEASURE_OPTIONS, input.measure) ? input.measure : (defaultSpec as DensityHeatmapSpec).measure,
325
+ groupBy: hasValue(GROUP_OPTIONS, input.groupBy) ? input.groupBy : (defaultSpec as DensityHeatmapSpec).groupBy,
326
+ scaleMode: hasValue(SCALE_OPTIONS, input.scaleMode) ? input.scaleMode : (defaultSpec as DensityHeatmapSpec).scaleMode,
327
+ showValues: readBool(input.showValues, (defaultSpec as DensityHeatmapSpec).showValues !== false),
328
+ };
329
+ return { value: spec, errors };
330
+ }
331
+
332
+ export function validateViewSpec(visualizer: VisualizerId, value: unknown): ValidationResult<ViewSpec> {
333
+ const result = normalizeViewSpec(visualizer, value);
334
+ return { value: result.errors.length ? null : result.value, errors: result.errors };
335
+ }
336
+
337
+ export function normalizePalette(value: unknown): string[] {
338
+ return Array.isArray(value) && value.length > 0 && value.every((c) => typeof c === "string") ? value : TV_PALETTES.Studio;
339
+ }
340
+
341
+ export function normalizeTimeviewConfig(raw: unknown): ValidationResult<TimeviewConfigV1> {
342
+ const errors: string[] = [];
343
+ if (!isRecord(raw)) return { value: null, errors: ["Config root must be a JSON object."] };
344
+ if (raw.v !== 1) errors.push("Config version must be 1.");
345
+ if (typeof raw.visualizer !== "string" || !(raw.visualizer in TV_VISUALIZER_BY_ID)) errors.push("visualizer must be a known Timeview visualizer.");
346
+
347
+ const visualizer = typeof raw.visualizer === "string" && raw.visualizer in TV_VISUALIZER_BY_ID ? (raw.visualizer as VisualizerId) : "bandedTimeline";
348
+ const dataset = validateTimeDataset(raw.dataset);
349
+ const spec = normalizeViewSpec(visualizer, raw.spec);
350
+ errors.push(...dataset.errors.map((error) => `dataset: ${error}`));
351
+ errors.push(...spec.errors.map((error) => `spec: ${error}`));
352
+
353
+ if (!dataset.value || !spec.value || errors.length) return { value: null, errors };
354
+
355
+ return {
356
+ value: {
357
+ v: 1,
358
+ visualizer,
359
+ dataset: dataset.value,
360
+ spec: spec.value,
361
+ palette: normalizePalette(raw.palette),
362
+ },
363
+ errors: [],
364
+ };
365
+ }
366
+
367
+ function base64UrlEncode(text: string): string {
368
+ const bytes = new TextEncoder().encode(text);
369
+ let binary = "";
370
+ bytes.forEach((byte) => {
371
+ binary += String.fromCharCode(byte);
372
+ });
373
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
374
+ }
375
+
376
+ function base64UrlDecode(text: string): string {
377
+ const padded = text.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(text.length / 4) * 4, "=");
378
+ const binary = atob(padded);
379
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
380
+ return new TextDecoder().decode(bytes);
381
+ }
382
+
383
+ export function encodeTimeviewConfig(config: TimeviewConfigV1): string {
384
+ return base64UrlEncode(JSON.stringify(config));
385
+ }
386
+
387
+ export function decodeTimeviewConfig(hashOrToken: string): ValidationResult<TimeviewConfigV1> {
388
+ const token = hashOrToken.startsWith("#tv=") ? hashOrToken.slice(4) : hashOrToken;
389
+ if (!token) return { value: null, errors: ["Missing #tv config token."] };
390
+ try {
391
+ return normalizeTimeviewConfig(JSON.parse(base64UrlDecode(token)) as unknown);
392
+ } catch (err) {
393
+ return { value: null, errors: [err instanceof Error ? err.message : "Invalid encoded config."] };
394
+ }
395
+ }
396
+
397
+ export function cloneTimeviewConfig(config: TimeviewConfigV1): TimeviewConfigV1 {
398
+ return clone(config);
399
+ }