@opendata-ai/openchart-engine 7.6.1 → 7.7.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.
- package/dist/index.d.ts +13 -0
- package/dist/index.js +1031 -187
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +6 -0
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +16 -0
- package/src/__tests__/axes.test.ts +3 -2
- package/src/__tests__/facet.test.ts +215 -0
- package/src/annotations/__tests__/compute.test.ts +18 -0
- package/src/annotations/__tests__/thinning.test.ts +137 -0
- package/src/annotations/compute.ts +3 -1
- package/src/annotations/thinning.ts +116 -0
- package/src/barlist/compile-barlist.ts +1 -0
- package/src/charts/bar/labels.ts +1 -0
- package/src/charts/column/labels.ts +1 -0
- package/src/charts/dot/labels.ts +1 -0
- package/src/charts/line/area.ts +16 -2
- package/src/charts/line/compute.ts +17 -10
- package/src/charts/pie/labels.ts +1 -0
- package/src/compile/__tests__/color-scale-range.test.ts +80 -1
- package/src/compile/color-scale-range.ts +44 -2
- package/src/compile.ts +314 -5
- package/src/compiler/__tests__/normalize-highlight.test.ts +103 -0
- package/src/compiler/__tests__/validate-highlight.test.ts +112 -0
- package/src/compiler/normalize.ts +67 -2
- package/src/compiler/types.ts +10 -0
- package/src/compiler/validate.ts +79 -0
- package/src/endpoint-labels/__tests__/compute.test.ts +57 -0
- package/src/endpoint-labels/compute.ts +179 -5
- package/src/endpoint-labels/predict.ts +17 -0
- package/src/layout/__tests__/scales-highlight.test.ts +192 -0
- package/src/layout/axes.ts +8 -2
- package/src/layout/dimensions.ts +6 -1
- package/src/layout/facet.ts +80 -0
- package/src/layout/plan.ts +16 -0
- package/src/legend/__tests__/redundancy.test.ts +108 -0
- package/src/legend/__tests__/suppression.test.ts +72 -0
- package/src/legend/compute.ts +18 -0
- package/src/legend/suppression.ts +27 -6
- package/src/tilemap/__tests__/compile-tilemap.test.ts +210 -0
- package/src/tilemap/compile-tilemap.ts +371 -117
- package/src/tilemap/types.ts +1 -0
|
@@ -217,6 +217,43 @@ function normalizeLabels(labels?: LabelSpec): NormalizedChartSpec['labels'] {
|
|
|
217
217
|
};
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Highlight normalization
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Normalize `encoding.color.highlight` to a flat string array.
|
|
226
|
+
* Warns when highlight values don't appear in the data.
|
|
227
|
+
*/
|
|
228
|
+
function normalizeHighlight(encoding: Encoding, data: DataRow[], warnings: string[]): string[] {
|
|
229
|
+
const color = encoding.color;
|
|
230
|
+
if (!color || !('field' in color)) return [];
|
|
231
|
+
const raw = (color as { highlight?: string | string[] }).highlight;
|
|
232
|
+
if (raw == null) return [];
|
|
233
|
+
const highlight = Array.isArray(raw) ? raw : [raw];
|
|
234
|
+
|
|
235
|
+
// Warn on unknown values
|
|
236
|
+
if (highlight.length > 0 && data.length > 0) {
|
|
237
|
+
const colorField = color.field;
|
|
238
|
+
const knownValues = new Set(data.map((row) => String(row[colorField])));
|
|
239
|
+
for (const v of highlight) {
|
|
240
|
+
if (!knownValues.has(v)) {
|
|
241
|
+
warnings.push(
|
|
242
|
+
`[openchart] encoding.color.highlight value "${v}" does not match any value in the "${colorField}" data column`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (highlight.length > 0 && color.scale?.range) {
|
|
249
|
+
warnings.push(
|
|
250
|
+
'[openchart] encoding.color.highlight has no effect on color assignment when encoding.color.scale.range is explicitly provided',
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return highlight;
|
|
255
|
+
}
|
|
256
|
+
|
|
220
257
|
// ---------------------------------------------------------------------------
|
|
221
258
|
// Spec-level normalization
|
|
222
259
|
// ---------------------------------------------------------------------------
|
|
@@ -250,13 +287,19 @@ function normalizeChartSpec(spec: ChartSpec, warnings: string[]): NormalizedChar
|
|
|
250
287
|
labels: normalizeLabels(spec.labels),
|
|
251
288
|
legend: spec.legend,
|
|
252
289
|
endpointLabels: spec.endpointLabels,
|
|
253
|
-
responsive: spec.responsive ?? true,
|
|
290
|
+
responsive: typeof spec.responsive === 'object' ? true : (spec.responsive ?? true),
|
|
291
|
+
autoThin:
|
|
292
|
+
typeof spec.responsive === 'object'
|
|
293
|
+
? (spec.responsive.autoThin ?? true)
|
|
294
|
+
: (spec.responsive ?? true),
|
|
254
295
|
theme: spec.theme ?? {},
|
|
255
296
|
darkMode: spec.darkMode ?? 'off',
|
|
256
297
|
hiddenSeries: spec.hiddenSeries ?? [],
|
|
257
298
|
seriesStyles: spec.seriesStyles ?? {},
|
|
258
299
|
watermark: spec.watermark ?? true,
|
|
300
|
+
highlight: normalizeHighlight(encoding, spec.data, warnings),
|
|
259
301
|
display,
|
|
302
|
+
resolve: spec.resolve,
|
|
260
303
|
// Default empty userExplicit; compileChart overwrites this with the real
|
|
261
304
|
// descriptor built from the raw expanded spec before normalize runs.
|
|
262
305
|
userExplicit: {
|
|
@@ -353,15 +396,36 @@ function normalizeTileMapSpec(spec: TileMapSpec, warnings: string[]): Normalized
|
|
|
353
396
|
data = Object.entries(spec.data).map(([state, value]) => ({ state, value }));
|
|
354
397
|
}
|
|
355
398
|
|
|
399
|
+
// Detect categorical mode: colors map provided, color encoding channel, or string values
|
|
400
|
+
const hasStringValues =
|
|
401
|
+
!Array.isArray(spec.data) && Object.values(spec.data).some((v) => typeof v === 'string');
|
|
402
|
+
const isCategorical =
|
|
403
|
+
spec.colors !== undefined || spec.encoding?.color !== undefined || hasStringValues;
|
|
404
|
+
|
|
405
|
+
// Warn on mixed number/string values in record map (likely a data quality issue)
|
|
406
|
+
if (hasStringValues && !Array.isArray(spec.data)) {
|
|
407
|
+
const hasNumericValues = Object.values(spec.data).some((v) => typeof v === 'number');
|
|
408
|
+
if (hasNumericValues) {
|
|
409
|
+
warnings.push(
|
|
410
|
+
'TileMap data: record map contains mixed number and string values. Treating as categorical. Use all numbers for quantitative or all strings for categorical.',
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
356
415
|
// Auto-generate encoding if not provided
|
|
357
416
|
let encoding = spec.encoding;
|
|
358
417
|
if (!encoding) {
|
|
359
418
|
encoding = {
|
|
360
419
|
state: { field: 'state', type: 'nominal' },
|
|
361
|
-
value: { field: 'value', type: 'quantitative' },
|
|
420
|
+
value: { field: 'value', type: isCategorical ? 'nominal' : 'quantitative' },
|
|
362
421
|
};
|
|
363
422
|
}
|
|
364
423
|
|
|
424
|
+
// For categorical record maps without explicit color encoding, use value as color channel
|
|
425
|
+
if (isCategorical && !encoding.color) {
|
|
426
|
+
encoding = { ...encoding, color: encoding.value };
|
|
427
|
+
}
|
|
428
|
+
|
|
365
429
|
// Count matched states and warn if low match ratio
|
|
366
430
|
let matchedCount = 0;
|
|
367
431
|
for (const row of data) {
|
|
@@ -383,6 +447,7 @@ function normalizeTileMapSpec(spec: TileMapSpec, warnings: string[]): Normalized
|
|
|
383
447
|
data,
|
|
384
448
|
encoding,
|
|
385
449
|
palette: spec.palette ?? 'blue',
|
|
450
|
+
colors: spec.colors,
|
|
386
451
|
chrome: normalizeChrome(spec.chrome),
|
|
387
452
|
legend: spec.legend,
|
|
388
453
|
theme: spec.theme ?? {},
|
package/src/compiler/types.ts
CHANGED
|
@@ -116,6 +116,8 @@ export interface NormalizedChartSpec {
|
|
|
116
116
|
/** Right-side endpoint labels column config (multi-series line/area only). */
|
|
117
117
|
endpointLabels?: boolean | import('@opendata-ai/openchart-core').EndpointLabelsConfig;
|
|
118
118
|
responsive: boolean;
|
|
119
|
+
/** Whether auto-thinning of annotations at narrow widths is enabled. Resolved from `responsive.autoThin`. */
|
|
120
|
+
autoThin: boolean;
|
|
119
121
|
theme: ThemeConfig;
|
|
120
122
|
darkMode: DarkMode;
|
|
121
123
|
/** Whether the tryOpenData.ai watermark is enabled. */
|
|
@@ -126,12 +128,20 @@ export interface NormalizedChartSpec {
|
|
|
126
128
|
seriesStyles: Record<string, import('@opendata-ai/openchart-core').SeriesStyle>;
|
|
127
129
|
/** Display mode controlling chrome/axes/legend stripping. Defaults to `'full'`. */
|
|
128
130
|
display: Display;
|
|
131
|
+
/** Resolve configuration for independent/shared scales in faceted charts. */
|
|
132
|
+
resolve?: import('@opendata-ai/openchart-core').ResolveConfig;
|
|
129
133
|
/**
|
|
130
134
|
* Which top-level fields the user explicitly set. Populated by compileChart
|
|
131
135
|
* from the raw expanded spec before normalization. NormalizeChartSpec runs
|
|
132
136
|
* with a default-empty descriptor; compileChart overwrites it post-normalize.
|
|
133
137
|
*/
|
|
134
138
|
userExplicit: UserExplicit;
|
|
139
|
+
/**
|
|
140
|
+
* Series values to visually emphasize. Non-highlighted series are muted to
|
|
141
|
+
* a neutral gray. Normalized from `encoding.color.highlight` (always an
|
|
142
|
+
* array; empty when no highlight is active).
|
|
143
|
+
*/
|
|
144
|
+
highlight: string[];
|
|
135
145
|
}
|
|
136
146
|
|
|
137
147
|
/** A TableSpec with all optional fields filled with sensible defaults. */
|
package/src/compiler/validate.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
13
|
type FieldType,
|
|
14
|
+
inferFieldType,
|
|
14
15
|
MARK_ENCODING_RULES,
|
|
15
16
|
MARK_TYPES,
|
|
16
17
|
type MarkType,
|
|
@@ -269,6 +270,84 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
|
|
|
269
270
|
}
|
|
270
271
|
}
|
|
271
272
|
|
|
273
|
+
// Validate highlight on the color channel
|
|
274
|
+
if (encoding) {
|
|
275
|
+
const colorCh = (encoding as Record<string, Record<string, unknown> | undefined>).color;
|
|
276
|
+
if (colorCh && colorCh.highlight != null) {
|
|
277
|
+
const hlVal = colorCh.highlight;
|
|
278
|
+
const isValidType =
|
|
279
|
+
typeof hlVal === 'string' ||
|
|
280
|
+
(Array.isArray(hlVal) && hlVal.every((v: unknown) => typeof v === 'string'));
|
|
281
|
+
if (!isValidType) {
|
|
282
|
+
errors.push({
|
|
283
|
+
message: 'Spec error: encoding.color.highlight must be a string or array of strings',
|
|
284
|
+
path: 'encoding.color.highlight',
|
|
285
|
+
code: 'INVALID_TYPE',
|
|
286
|
+
suggestion: 'Provide a series name (string) or an array of series names.',
|
|
287
|
+
});
|
|
288
|
+
} else {
|
|
289
|
+
const colorType =
|
|
290
|
+
(colorCh.type as string | undefined) ??
|
|
291
|
+
(colorCh.field && Array.isArray(spec.data) && spec.data.length > 0
|
|
292
|
+
? inferFieldType(spec.data as Record<string, unknown>[], colorCh.field as string)
|
|
293
|
+
: undefined);
|
|
294
|
+
if (colorType === 'quantitative') {
|
|
295
|
+
errors.push({
|
|
296
|
+
message:
|
|
297
|
+
'Spec error: encoding.color.highlight is not supported on quantitative color channels',
|
|
298
|
+
path: 'encoding.color.highlight',
|
|
299
|
+
code: 'INVALID_VALUE',
|
|
300
|
+
suggestion:
|
|
301
|
+
'Highlight works with nominal/ordinal color (categorical series). Remove highlight or change encoding.color.type to nominal.',
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Validate facet channel if provided
|
|
309
|
+
if (encoding.facet && typeof encoding.facet === 'object') {
|
|
310
|
+
const facet = encoding.facet as Record<string, unknown>;
|
|
311
|
+
if (!facet.field || typeof facet.field !== 'string') {
|
|
312
|
+
errors.push({
|
|
313
|
+
message: 'Spec error: encoding.facet.field is required and must be a non-empty string',
|
|
314
|
+
path: 'encoding.facet.field',
|
|
315
|
+
code: 'MISSING_FIELD',
|
|
316
|
+
suggestion:
|
|
317
|
+
'Add a field property to encoding.facet (e.g. { field: "category", type: "nominal" })',
|
|
318
|
+
});
|
|
319
|
+
} else if (
|
|
320
|
+
!dataColumns.has(facet.field as string) &&
|
|
321
|
+
!transformFields.has(facet.field as string)
|
|
322
|
+
) {
|
|
323
|
+
errors.push({
|
|
324
|
+
message: `Spec error: encoding.facet.field "${facet.field}" does not exist in data. Available columns: ${availableColumns}`,
|
|
325
|
+
path: 'encoding.facet.field',
|
|
326
|
+
code: 'DATA_FIELD_MISSING',
|
|
327
|
+
suggestion: `Use one of the available data columns: ${availableColumns}`,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
if (!facet.type || (facet.type !== 'nominal' && facet.type !== 'ordinal')) {
|
|
331
|
+
errors.push({
|
|
332
|
+
message: `Spec error: encoding.facet.type must be "nominal" or "ordinal"${facet.type ? `, got "${facet.type}"` : ''}`,
|
|
333
|
+
path: 'encoding.facet.type',
|
|
334
|
+
code: facet.type ? 'INVALID_VALUE' : 'MISSING_FIELD',
|
|
335
|
+
suggestion: 'Set encoding.facet.type to "nominal" or "ordinal"',
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
if (facet.columns !== undefined) {
|
|
339
|
+
const cols = Number(facet.columns);
|
|
340
|
+
if (!Number.isInteger(cols) || cols < 1) {
|
|
341
|
+
errors.push({
|
|
342
|
+
message: 'Spec error: encoding.facet.columns must be a positive integer',
|
|
343
|
+
path: 'encoding.facet.columns',
|
|
344
|
+
code: 'INVALID_VALUE',
|
|
345
|
+
suggestion: 'Set columns to a positive integer (e.g. 3)',
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
272
351
|
// Validate darkMode if provided
|
|
273
352
|
if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) {
|
|
274
353
|
errors.push({
|
|
@@ -439,4 +439,61 @@ describe('computeEndpointLabels', () => {
|
|
|
439
439
|
expect(entry.color).toBe(lineColor);
|
|
440
440
|
}
|
|
441
441
|
});
|
|
442
|
+
|
|
443
|
+
it('produces leading entries when ends: "both"', () => {
|
|
444
|
+
const spec = makeSpec({ endpointLabels: { ends: 'both' } });
|
|
445
|
+
const marks: Mark[] = [makeLineMark('US', 100, 40), makeLineMark('UK', 200, 35, '#cc6633')];
|
|
446
|
+
const layout = computeEndpointLabels(spec, marks, theme, chartArea);
|
|
447
|
+
|
|
448
|
+
expect(layout.entries).toHaveLength(2);
|
|
449
|
+
expect(layout.leading).toBeDefined();
|
|
450
|
+
expect(layout.leading).toHaveLength(2);
|
|
451
|
+
const leadingKeys = layout.leading!.map((e) => e.seriesKey).sort();
|
|
452
|
+
expect(leadingKeys).toEqual(['UK', 'US']);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('positions leading column to the left of the chart area', () => {
|
|
456
|
+
const spec = makeSpec({ endpointLabels: { ends: 'both' } });
|
|
457
|
+
const marks: Mark[] = [makeLineMark('US', 100, 40), makeLineMark('UK', 200, 35, '#cc6633')];
|
|
458
|
+
const layout = computeEndpointLabels(spec, marks, theme, chartArea);
|
|
459
|
+
|
|
460
|
+
expect(layout.leadingBounds).toBeDefined();
|
|
461
|
+
expect(layout.leadingBounds!.x + layout.leadingBounds!.width).toBeLessThanOrEqual(chartArea.x);
|
|
462
|
+
expect(layout.leadingBounds!.width).toBeGreaterThan(0);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('leading entries use the first data point value', () => {
|
|
466
|
+
const spec = makeSpec({ endpointLabels: { ends: 'both' } });
|
|
467
|
+
const marks: Mark[] = [makeLineMark('US', 100, 40), makeLineMark('UK', 200, 35, '#cc6633')];
|
|
468
|
+
const layout = computeEndpointLabels(spec, marks, theme, chartArea);
|
|
469
|
+
|
|
470
|
+
const usTrailing = layout.entries.find((e) => e.seriesKey === 'US');
|
|
471
|
+
const usLeading = layout.leading!.find((e) => e.seriesKey === 'US');
|
|
472
|
+
expect(usTrailing).toBeDefined();
|
|
473
|
+
expect(usLeading).toBeDefined();
|
|
474
|
+
// The first data point has value - 5 (35 for US), the last has the full value (40).
|
|
475
|
+
expect(usLeading!.value).not.toBe(usTrailing!.value);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('does not produce leading entries when ends is unset (default)', () => {
|
|
479
|
+
const spec = makeSpec();
|
|
480
|
+
const marks: Mark[] = [makeLineMark('US', 100, 40), makeLineMark('UK', 200, 35, '#cc6633')];
|
|
481
|
+
const layout = computeEndpointLabels(spec, marks, theme, chartArea);
|
|
482
|
+
|
|
483
|
+
expect(layout.leading).toBeUndefined();
|
|
484
|
+
expect(layout.leadingBounds).toBeUndefined();
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('leading entries get markers at the first data point', () => {
|
|
488
|
+
const spec = makeSpec({ endpointLabels: { ends: 'both' } });
|
|
489
|
+
const marks: Mark[] = [makeLineMark('US', 100, 40), makeLineMark('UK', 200, 35, '#cc6633')];
|
|
490
|
+
const layout = computeEndpointLabels(spec, marks, theme, chartArea);
|
|
491
|
+
|
|
492
|
+
for (const entry of layout.leading!) {
|
|
493
|
+
expect(entry.marker).toBeDefined();
|
|
494
|
+
expect(entry.marker!.dataX).toBe(chartArea.x);
|
|
495
|
+
// Leading marker offset: x = dataX - radius (left side).
|
|
496
|
+
expect(entry.marker!.x).toBe(chartArea.x - ENDPOINT_MARKER_RADIUS);
|
|
497
|
+
}
|
|
498
|
+
});
|
|
442
499
|
});
|
|
@@ -68,6 +68,7 @@ function emptyLayout(theme: ResolvedTheme): EndpointLabelsLayout {
|
|
|
68
68
|
fontWeight: ENDPOINT_VALUE_FONT_WEIGHT,
|
|
69
69
|
fill: theme.colors.annotationText ?? theme.colors.text,
|
|
70
70
|
lineHeight: ENDPOINT_LINE_HEIGHT,
|
|
71
|
+
fontVariant: 'tabular-nums',
|
|
71
72
|
};
|
|
72
73
|
return {
|
|
73
74
|
entries: [],
|
|
@@ -91,8 +92,6 @@ function lastDataPoint(mark: LineMark | AreaMark): { x: number; y: number } | nu
|
|
|
91
92
|
const last = mark.dataPoints[mark.dataPoints.length - 1];
|
|
92
93
|
return { x: last.x, y: last.y };
|
|
93
94
|
}
|
|
94
|
-
// Fallback for marks compiled without dataPoints (rare): use the last
|
|
95
|
-
// geometry point. For areas this is the top boundary.
|
|
96
95
|
if (mark.type === 'line' && mark.points.length > 0) {
|
|
97
96
|
const last = mark.points[mark.points.length - 1];
|
|
98
97
|
return { x: last.x, y: last.y };
|
|
@@ -104,7 +103,24 @@ function lastDataPoint(mark: LineMark | AreaMark): { x: number; y: number } | nu
|
|
|
104
103
|
return null;
|
|
105
104
|
}
|
|
106
105
|
|
|
107
|
-
/** Get the
|
|
106
|
+
/** Get the first data-point pixel position for a line/area mark. */
|
|
107
|
+
function firstDataPoint(mark: LineMark | AreaMark): { x: number; y: number } | null {
|
|
108
|
+
if (mark.dataPoints && mark.dataPoints.length > 0) {
|
|
109
|
+
const first = mark.dataPoints[0];
|
|
110
|
+
return { x: first.x, y: first.y };
|
|
111
|
+
}
|
|
112
|
+
if (mark.type === 'line' && mark.points.length > 0) {
|
|
113
|
+
const first = mark.points[0];
|
|
114
|
+
return { x: first.x, y: first.y };
|
|
115
|
+
}
|
|
116
|
+
if (mark.type === 'area' && mark.topPoints.length > 0) {
|
|
117
|
+
const first = mark.topPoints[0];
|
|
118
|
+
return { x: first.x, y: first.y };
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Get the value to display from the last data row. */
|
|
108
124
|
function readValue(
|
|
109
125
|
mark: LineMark | AreaMark,
|
|
110
126
|
valueField: string | undefined,
|
|
@@ -123,6 +139,25 @@ function readValue(
|
|
|
123
139
|
return null;
|
|
124
140
|
}
|
|
125
141
|
|
|
142
|
+
/** Get the value to display from the first data row. */
|
|
143
|
+
function readFirstValue(
|
|
144
|
+
mark: LineMark | AreaMark,
|
|
145
|
+
valueField: string | undefined,
|
|
146
|
+
): number | string | null {
|
|
147
|
+
if (!valueField) return null;
|
|
148
|
+
const dp = mark.dataPoints;
|
|
149
|
+
if (dp && dp.length > 0) {
|
|
150
|
+
const datum = dp[0].datum;
|
|
151
|
+
const v = datum[valueField];
|
|
152
|
+
return typeof v === 'number' || typeof v === 'string' ? v : null;
|
|
153
|
+
}
|
|
154
|
+
if (mark.data.length > 0) {
|
|
155
|
+
const v = mark.data[0][valueField];
|
|
156
|
+
return typeof v === 'number' || typeof v === 'string' ? v : null;
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
126
161
|
/**
|
|
127
162
|
* Local collision sweep: keep each label anchored to its line's `dataY` and
|
|
128
163
|
* only displace neighbors that actually overlap.
|
|
@@ -213,19 +248,26 @@ export function computeEndpointLabels(
|
|
|
213
248
|
theme: ResolvedTheme,
|
|
214
249
|
chartArea: Rect,
|
|
215
250
|
strategy?: LayoutStrategy,
|
|
251
|
+
endpointLabelsDemoted?: boolean,
|
|
216
252
|
): EndpointLabelsLayout {
|
|
217
253
|
// Compact strategy: drop the column entirely. The traditional legend takes
|
|
218
254
|
// over series identification at the compact breakpoint.
|
|
219
255
|
if (strategy?.labelMode === 'none') return emptyLayout(theme);
|
|
220
256
|
|
|
257
|
+
const highlight = spec.highlight ?? [];
|
|
258
|
+
const highlightActive = highlight.length > 0;
|
|
221
259
|
const seriesCount = countColorSeries(spec);
|
|
222
260
|
const sup = resolveSuppression(spec, {
|
|
223
261
|
seriesCount,
|
|
224
262
|
// The 'none' branch above returned; by here labelMode is not 'none'.
|
|
225
263
|
labelsHiddenByStrategy: false,
|
|
226
264
|
labelsDensityNone: spec.labels.density === 'none',
|
|
265
|
+
endpointLabelsDemoted,
|
|
227
266
|
});
|
|
228
|
-
|
|
267
|
+
const epExplicitlyOff =
|
|
268
|
+
spec.endpointLabels === false ||
|
|
269
|
+
(typeof spec.endpointLabels === 'object' && spec.endpointLabels?.show === false);
|
|
270
|
+
if (!sup.showEndpointLabels && !(highlightActive && !epExplicitlyOff)) return emptyLayout(theme);
|
|
229
271
|
|
|
230
272
|
// Dedupe by seriesKey: for area charts, the engine emits BOTH an AreaMark
|
|
231
273
|
// and a derived LineMark per series (see `linesFromAreas` in
|
|
@@ -271,6 +313,7 @@ export function computeEndpointLabels(
|
|
|
271
313
|
fontWeight: ENDPOINT_VALUE_FONT_WEIGHT,
|
|
272
314
|
fill: theme.colors.annotationText ?? theme.colors.text,
|
|
273
315
|
lineHeight: ENDPOINT_LINE_HEIGHT,
|
|
316
|
+
fontVariant: 'tabular-nums',
|
|
274
317
|
};
|
|
275
318
|
|
|
276
319
|
// First pass: build entries with wrapped labels and dataY (no positions yet).
|
|
@@ -291,6 +334,9 @@ export function computeEndpointLabels(
|
|
|
291
334
|
for (const mark of lineOrAreaMarks) {
|
|
292
335
|
const seriesKey = mark.seriesKey;
|
|
293
336
|
if (!seriesKey) continue;
|
|
337
|
+
|
|
338
|
+
if (highlightActive && !highlight.includes(seriesKey)) continue;
|
|
339
|
+
|
|
294
340
|
const last = lastDataPoint(mark);
|
|
295
341
|
if (!last) continue;
|
|
296
342
|
|
|
@@ -338,7 +384,8 @@ export function computeEndpointLabels(
|
|
|
338
384
|
});
|
|
339
385
|
}
|
|
340
386
|
|
|
341
|
-
if (provisional.length
|
|
387
|
+
if (provisional.length === 0) return emptyLayout(theme);
|
|
388
|
+
if (provisional.length < 2 && !highlightActive) return emptyLayout(theme);
|
|
342
389
|
|
|
343
390
|
// Bidirectional collision sweep. Each entry's natural top is `dataY - labelFontSize/2`
|
|
344
391
|
// so the label's first-line baseline-center aligns with the line's last data point.
|
|
@@ -401,6 +448,131 @@ export function computeEndpointLabels(
|
|
|
401
448
|
return entry;
|
|
402
449
|
});
|
|
403
450
|
|
|
451
|
+
// Leading column (left side) for `ends: 'both'` mode.
|
|
452
|
+
const endsBoth = config?.ends === 'both';
|
|
453
|
+
let leading: EndpointLabelEntry[] | undefined;
|
|
454
|
+
let leadingBounds: { x: number; y: number; width: number; height: number } | undefined;
|
|
455
|
+
|
|
456
|
+
if (endsBoth) {
|
|
457
|
+
type LeadingProvisional = {
|
|
458
|
+
seriesKey: string;
|
|
459
|
+
labelLines: string[];
|
|
460
|
+
value: string;
|
|
461
|
+
color: string;
|
|
462
|
+
dataX: number;
|
|
463
|
+
dataY: number;
|
|
464
|
+
height: number;
|
|
465
|
+
width: number;
|
|
466
|
+
};
|
|
467
|
+
const leadingProvisional: LeadingProvisional[] = [];
|
|
468
|
+
|
|
469
|
+
for (const mark of lineOrAreaMarks) {
|
|
470
|
+
const seriesKey = mark.seriesKey;
|
|
471
|
+
if (!seriesKey) continue;
|
|
472
|
+
|
|
473
|
+
if (highlightActive && !highlight.includes(seriesKey)) continue;
|
|
474
|
+
|
|
475
|
+
const first = firstDataPoint(mark);
|
|
476
|
+
if (!first) continue;
|
|
477
|
+
|
|
478
|
+
const labelLines = wrapText(
|
|
479
|
+
seriesKey,
|
|
480
|
+
ENDPOINT_LABEL_FONT_SIZE,
|
|
481
|
+
ENDPOINT_LABEL_FONT_WEIGHT,
|
|
482
|
+
wrapWidth,
|
|
483
|
+
);
|
|
484
|
+
const rawValue = readFirstValue(mark, valueField);
|
|
485
|
+
const value = formatEndpointValue(rawValue, formatString);
|
|
486
|
+
|
|
487
|
+
let entryWidth = 0;
|
|
488
|
+
for (const line of labelLines) {
|
|
489
|
+
const w = estimateTextWidth(line, ENDPOINT_LABEL_FONT_SIZE, ENDPOINT_LABEL_FONT_WEIGHT);
|
|
490
|
+
if (w > entryWidth) entryWidth = w;
|
|
491
|
+
}
|
|
492
|
+
const valueWidth = value
|
|
493
|
+
? estimateTextWidth(value, ENDPOINT_VALUE_FONT_SIZE, ENDPOINT_VALUE_FONT_WEIGHT)
|
|
494
|
+
: 0;
|
|
495
|
+
if (valueWidth > entryWidth) entryWidth = valueWidth;
|
|
496
|
+
|
|
497
|
+
const labelHeight = labelLines.length * labelLineHeight;
|
|
498
|
+
const valueHeight = value ? valueLineHeight : 0;
|
|
499
|
+
const entryHeight = labelHeight + (value ? ENDPOINT_VALUE_GAP : 0) + valueHeight;
|
|
500
|
+
|
|
501
|
+
const color =
|
|
502
|
+
mark.type === 'line'
|
|
503
|
+
? mark.stroke
|
|
504
|
+
: (mark.stroke ??
|
|
505
|
+
(typeof mark.fill === 'string' ? mark.fill : theme.colors.categorical[0]));
|
|
506
|
+
|
|
507
|
+
leadingProvisional.push({
|
|
508
|
+
seriesKey,
|
|
509
|
+
labelLines,
|
|
510
|
+
value,
|
|
511
|
+
color,
|
|
512
|
+
dataX: first.x,
|
|
513
|
+
dataY: first.y,
|
|
514
|
+
height: entryHeight,
|
|
515
|
+
width: entryWidth,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (leadingProvisional.length >= 2 || (leadingProvisional.length === 1 && highlightActive)) {
|
|
520
|
+
const leadingSweepInput = leadingProvisional.map((p, i) => ({
|
|
521
|
+
naturalTop: p.dataY - ENDPOINT_LABEL_FONT_SIZE / 2,
|
|
522
|
+
height: p.height + ENDPOINT_ENTRY_GAP,
|
|
523
|
+
index: i,
|
|
524
|
+
}));
|
|
525
|
+
const leadingSweptTops = bidirectionalSweep(
|
|
526
|
+
leadingSweepInput,
|
|
527
|
+
chartArea.y,
|
|
528
|
+
chartArea.y + chartArea.height,
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
const leadingColumnWidth = Math.max(
|
|
532
|
+
ENDPOINT_SWATCH_SIZE +
|
|
533
|
+
ENDPOINT_GAP +
|
|
534
|
+
Math.max(...leadingProvisional.map((p) => p.width), 0) +
|
|
535
|
+
4,
|
|
536
|
+
32,
|
|
537
|
+
);
|
|
538
|
+
const leadingColumnX = chartArea.x - ENDPOINT_COLUMN_GAP - leadingColumnWidth;
|
|
539
|
+
|
|
540
|
+
leading = leadingProvisional.map((p, i) => {
|
|
541
|
+
const labelY = leadingSweptTops[i];
|
|
542
|
+
const swatchRowY = labelY + ENDPOINT_LABEL_FONT_SIZE / 2;
|
|
543
|
+
const displaced = Math.abs(swatchRowY - p.dataY) > ENDPOINT_LEADER_THRESHOLD;
|
|
544
|
+
const entry: EndpointLabelEntry = {
|
|
545
|
+
seriesKey: p.seriesKey,
|
|
546
|
+
labelLines: p.labelLines,
|
|
547
|
+
value: p.value,
|
|
548
|
+
color: p.color,
|
|
549
|
+
dataY: p.dataY,
|
|
550
|
+
labelY,
|
|
551
|
+
showLeader: showLeader && displaced,
|
|
552
|
+
};
|
|
553
|
+
if (showMarker) {
|
|
554
|
+
entry.marker = {
|
|
555
|
+
x: p.dataX - markerRadius,
|
|
556
|
+
y: p.dataY,
|
|
557
|
+
dataX: p.dataX,
|
|
558
|
+
fill: markerFill,
|
|
559
|
+
stroke: config?.markerStyle?.stroke ?? p.color,
|
|
560
|
+
strokeWidth: markerStrokeWidth,
|
|
561
|
+
radius: markerRadius,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
return entry;
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
leadingBounds = {
|
|
568
|
+
x: leadingColumnX,
|
|
569
|
+
y: chartArea.y,
|
|
570
|
+
width: leadingColumnWidth,
|
|
571
|
+
height: chartArea.height,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
404
576
|
return {
|
|
405
577
|
entries,
|
|
406
578
|
bounds: {
|
|
@@ -415,5 +587,7 @@ export function computeEndpointLabels(
|
|
|
415
587
|
gap: ENDPOINT_GAP,
|
|
416
588
|
valueGap: ENDPOINT_VALUE_GAP,
|
|
417
589
|
swatchChipFill: theme.colors.annotationFill ?? theme.colors.background,
|
|
590
|
+
leading,
|
|
591
|
+
leadingBounds,
|
|
418
592
|
};
|
|
419
593
|
}
|
|
@@ -16,6 +16,7 @@ import type { ResolvedTheme } from '@opendata-ai/openchart-core';
|
|
|
16
16
|
import { estimateTextWidth, wrapText } from '@opendata-ai/openchart-core';
|
|
17
17
|
|
|
18
18
|
import type { NormalizedChartSpec } from '../compiler/types';
|
|
19
|
+
import { endpointLabelsExplicitlyOn } from '../legend/suppression';
|
|
19
20
|
import {
|
|
20
21
|
ENDPOINT_GAP,
|
|
21
22
|
ENDPOINT_LABEL_FONT_SIZE,
|
|
@@ -59,6 +60,13 @@ export function predictEndpointLabelsWidth(
|
|
|
59
60
|
// for the bare boolean; also handle the object form `{ show: false }`.)
|
|
60
61
|
if (typeof spec.endpointLabels === 'object' && spec.endpointLabels?.show === false) return 0;
|
|
61
62
|
|
|
63
|
+
// Series-count cutoff: above maxSeries (default 8), skip endpoint labels
|
|
64
|
+
// unless the user explicitly enabled them. Uses the same explicit-on logic
|
|
65
|
+
// as suppression.ts to keep predict and render decisions aligned.
|
|
66
|
+
const maxSeries =
|
|
67
|
+
(typeof spec.endpointLabels === 'object' && spec.endpointLabels?.maxSeries) || 8;
|
|
68
|
+
if (!endpointLabelsExplicitlyOn(spec) && seriesNames.size > maxSeries) return 0;
|
|
69
|
+
|
|
62
70
|
const config = typeof spec.endpointLabels === 'object' ? spec.endpointLabels : undefined;
|
|
63
71
|
const wrapWidth = config?.width ?? ENDPOINT_WRAP_WIDTH_DEFAULT;
|
|
64
72
|
|
|
@@ -106,3 +114,12 @@ export function predictEndpointLabelsWidth(
|
|
|
106
114
|
const textColumn = Math.max(maxLabelWidth, valueWidth);
|
|
107
115
|
return ENDPOINT_SWATCH_SIZE + ENDPOINT_GAP + textColumn + 4;
|
|
108
116
|
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Returns true when the spec requests both-ends endpoint labels.
|
|
120
|
+
* Used by `computeDimensions` to also reserve left margin space.
|
|
121
|
+
*/
|
|
122
|
+
export function isEndsBoth(spec: NormalizedChartSpec): boolean {
|
|
123
|
+
const ep = spec.endpointLabels;
|
|
124
|
+
return typeof ep === 'object' && ep != null && ep.ends === 'both';
|
|
125
|
+
}
|