@opendata-ai/openchart-engine 6.25.4 → 6.27.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 +82 -4
- package/dist/index.js +1027 -76
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +33 -0
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +6 -0
- package/src/__tests__/compile-chart.test.ts +301 -0
- package/src/__tests__/compound-labels.test.ts +147 -0
- package/src/charts/line/area.ts +1 -1
- package/src/charts/line/compute.ts +7 -1
- package/src/compile.ts +222 -17
- package/src/compiler/normalize.ts +83 -1
- package/src/compiler/types.ts +41 -1
- package/src/compiler/validate.ts +124 -5
- package/src/index.ts +16 -1
- package/src/layout/axes/ticks.ts +34 -2
- package/src/layout/axes.ts +36 -3
- package/src/layout/dimensions.ts +98 -5
- package/src/legend/compute.ts +6 -1
- package/src/sankey/compile-sankey.ts +1 -1
- package/src/tilemap/__tests__/compile-tilemap.test.ts +322 -0
- package/src/tilemap/compile-tilemap.ts +383 -0
- package/src/tilemap/layout.ts +172 -0
- package/src/tilemap/types.ts +32 -0
- package/src/transforms/__tests__/filter-relative.test.ts +202 -0
- package/src/transforms/__tests__/window.test.ts +286 -0
- package/src/transforms/filter.ts +108 -3
- package/src/transforms/index.ts +5 -1
- package/src/transforms/predicates.ts +39 -9
- package/src/transforms/window.ts +185 -0
package/src/compiler/validate.ts
CHANGED
|
@@ -645,6 +645,120 @@ function validateSankeySpec(spec: Record<string, unknown>, errors: ValidationErr
|
|
|
645
645
|
}
|
|
646
646
|
}
|
|
647
647
|
|
|
648
|
+
// ---------------------------------------------------------------------------
|
|
649
|
+
// TileMap validation
|
|
650
|
+
// ---------------------------------------------------------------------------
|
|
651
|
+
|
|
652
|
+
function validateTileMapSpec(spec: Record<string, unknown>, errors: ValidationError[]): void {
|
|
653
|
+
// Validate data (can be record or array)
|
|
654
|
+
if (!spec.data || typeof spec.data !== 'object') {
|
|
655
|
+
errors.push({
|
|
656
|
+
message: 'Spec error: tilemap spec requires a "data" field (record or array)',
|
|
657
|
+
path: 'data',
|
|
658
|
+
code: 'INVALID_TYPE',
|
|
659
|
+
suggestion:
|
|
660
|
+
'Provide data as either a record mapping state codes to values (e.g. { "CA": 12000, "TX": 8500 }) or an array of objects with state and value fields',
|
|
661
|
+
});
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// If data is an object (record), validate it has at least one entry
|
|
666
|
+
if (!Array.isArray(spec.data) && Object.keys(spec.data as Record<string, unknown>).length === 0) {
|
|
667
|
+
errors.push({
|
|
668
|
+
message: 'Spec error: "data" must have at least one entry',
|
|
669
|
+
path: 'data',
|
|
670
|
+
code: 'EMPTY_DATA',
|
|
671
|
+
suggestion: 'Add at least one state-value pair, e.g. { "CA": 12000 }',
|
|
672
|
+
});
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// If data is an array, validate it's non-empty
|
|
677
|
+
if (Array.isArray(spec.data)) {
|
|
678
|
+
if (spec.data.length === 0) {
|
|
679
|
+
errors.push({
|
|
680
|
+
message: 'Spec error: "data" array must be non-empty',
|
|
681
|
+
path: 'data',
|
|
682
|
+
code: 'EMPTY_DATA',
|
|
683
|
+
suggestion: 'Add at least one data row',
|
|
684
|
+
});
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const firstRow = spec.data[0] as unknown;
|
|
689
|
+
if (typeof firstRow !== 'object' || firstRow === null || Array.isArray(firstRow)) {
|
|
690
|
+
errors.push({
|
|
691
|
+
message: 'Spec error: each item in "data" must be a plain object',
|
|
692
|
+
path: 'data[0]',
|
|
693
|
+
code: 'INVALID_TYPE',
|
|
694
|
+
suggestion: 'Each data item should be an object, e.g. { state: "CA", value: 12000 }',
|
|
695
|
+
});
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// If data is array, encoding is required
|
|
700
|
+
if (!spec.encoding || typeof spec.encoding !== 'object') {
|
|
701
|
+
errors.push({
|
|
702
|
+
message:
|
|
703
|
+
'Spec error: tilemap spec with array data requires an "encoding" object with state and value channels',
|
|
704
|
+
path: 'encoding',
|
|
705
|
+
code: 'MISSING_FIELD',
|
|
706
|
+
suggestion:
|
|
707
|
+
'Add an encoding object, e.g. encoding: { state: { field: "state", type: "nominal" }, value: { field: "value", type: "quantitative" } }',
|
|
708
|
+
});
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const encoding = spec.encoding as Record<string, unknown>;
|
|
713
|
+
const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
|
|
714
|
+
const availableColumns = [...dataColumns].join(', ');
|
|
715
|
+
|
|
716
|
+
// Required channels
|
|
717
|
+
for (const channel of ['state', 'value'] as const) {
|
|
718
|
+
const ch = encoding[channel] as Record<string, unknown> | undefined;
|
|
719
|
+
if (!ch || typeof ch !== 'object') {
|
|
720
|
+
errors.push({
|
|
721
|
+
message: `Spec error: tilemap encoding requires "${channel}" channel`,
|
|
722
|
+
path: `encoding.${channel}`,
|
|
723
|
+
code: 'MISSING_FIELD',
|
|
724
|
+
suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${[...dataColumns][0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
|
|
725
|
+
});
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (!ch.field || typeof ch.field !== 'string') {
|
|
730
|
+
errors.push({
|
|
731
|
+
message: `Spec error: encoding.${channel} must have a "field" string`,
|
|
732
|
+
path: `encoding.${channel}.field`,
|
|
733
|
+
code: 'MISSING_FIELD',
|
|
734
|
+
suggestion: `Add a field name from your data columns: ${availableColumns}`,
|
|
735
|
+
});
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if (!dataColumns.has(ch.field as string)) {
|
|
740
|
+
errors.push({
|
|
741
|
+
message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist in data. Available columns: ${availableColumns}`,
|
|
742
|
+
path: `encoding.${channel}.field`,
|
|
743
|
+
code: 'DATA_FIELD_MISSING',
|
|
744
|
+
suggestion: `Use one of the available data columns: ${availableColumns}`,
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Validate darkMode if provided
|
|
751
|
+
if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) {
|
|
752
|
+
errors.push({
|
|
753
|
+
message: 'Spec error: darkMode must be "auto", "force", or "off"',
|
|
754
|
+
path: 'darkMode',
|
|
755
|
+
code: 'INVALID_VALUE',
|
|
756
|
+
suggestion:
|
|
757
|
+
'Use one of: "auto" (system preference), "force" (always dark), or "off" (always light)',
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
648
762
|
// ---------------------------------------------------------------------------
|
|
649
763
|
// Layer validation
|
|
650
764
|
// ---------------------------------------------------------------------------
|
|
@@ -775,24 +889,27 @@ export function validateSpec(spec: unknown): ValidationResult {
|
|
|
775
889
|
// - Chart specs have a 'mark' field (string or object with type property)
|
|
776
890
|
// - Table specs have type: 'table'
|
|
777
891
|
// - Graph specs have type: 'graph'
|
|
892
|
+
// - Sankey specs have type: 'sankey'
|
|
893
|
+
// - TileMap specs have type: 'tilemap'
|
|
778
894
|
const hasLayer = 'layer' in obj && Array.isArray(obj.layer);
|
|
779
895
|
const hasMark = 'mark' in obj;
|
|
780
896
|
const isTable = obj.type === 'table';
|
|
781
897
|
const isGraph = obj.type === 'graph';
|
|
782
898
|
const isSankey = obj.type === 'sankey';
|
|
783
|
-
const
|
|
784
|
-
const
|
|
899
|
+
const isTileMap = obj.type === 'tilemap';
|
|
900
|
+
const isLayer = hasLayer && !isTable && !isGraph && !isSankey && !isTileMap;
|
|
901
|
+
const isChart = hasMark && !hasLayer && !isTable && !isGraph && !isSankey && !isTileMap;
|
|
785
902
|
|
|
786
|
-
if (!isChart && !isTable && !isGraph && !isSankey && !isLayer) {
|
|
903
|
+
if (!isChart && !isTable && !isGraph && !isSankey && !isTileMap && !isLayer) {
|
|
787
904
|
return {
|
|
788
905
|
valid: false,
|
|
789
906
|
errors: [
|
|
790
907
|
{
|
|
791
908
|
message:
|
|
792
|
-
'Spec error: spec must have a "mark" field for charts, a "layer" array for layered charts, or a "type" field for tables/graphs/sankey',
|
|
909
|
+
'Spec error: spec must have a "mark" field for charts, a "layer" array for layered charts, or a "type" field for tables/graphs/sankey/tilemap',
|
|
793
910
|
path: 'mark',
|
|
794
911
|
code: 'MISSING_FIELD',
|
|
795
|
-
suggestion: `Add a "mark" field for charts (e.g. mark: "bar"), a "layer" array for layered charts, or a "type" field for tables/graphs/sankey (type: "table", type: "graph", or type: "
|
|
912
|
+
suggestion: `Add a "mark" field for charts (e.g. mark: "bar"), a "layer" array for layered charts, or a "type" field for tables/graphs/sankey/tilemap (type: "table", type: "graph", type: "sankey", or type: "tilemap"). Valid mark types: ${[...MARK_TYPES].join(', ')}`,
|
|
796
913
|
},
|
|
797
914
|
],
|
|
798
915
|
normalized: null,
|
|
@@ -837,6 +954,8 @@ export function validateSpec(spec: unknown): ValidationResult {
|
|
|
837
954
|
validateGraphSpec(obj, errors);
|
|
838
955
|
} else if (isSankey) {
|
|
839
956
|
validateSankeySpec(obj, errors);
|
|
957
|
+
} else if (isTileMap) {
|
|
958
|
+
validateTileMapSpec(obj, errors);
|
|
840
959
|
}
|
|
841
960
|
|
|
842
961
|
if (errors.length > 0) {
|
package/src/index.ts
CHANGED
|
@@ -12,7 +12,14 @@
|
|
|
12
12
|
// Main compile API
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
14
14
|
|
|
15
|
-
export {
|
|
15
|
+
export {
|
|
16
|
+
compileChart,
|
|
17
|
+
compileGraph,
|
|
18
|
+
compileLayer,
|
|
19
|
+
compileSankey,
|
|
20
|
+
compileTable,
|
|
21
|
+
compileTileMap,
|
|
22
|
+
} from './compile';
|
|
16
23
|
|
|
17
24
|
// ---------------------------------------------------------------------------
|
|
18
25
|
// Animation resolution
|
|
@@ -37,6 +44,12 @@ export type {
|
|
|
37
44
|
|
|
38
45
|
export type { NormalizedSankeySpec } from './sankey/types';
|
|
39
46
|
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// TileMap compilation types
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
export type { NormalizedTileMapSpec } from './tilemap/types';
|
|
52
|
+
|
|
40
53
|
// ---------------------------------------------------------------------------
|
|
41
54
|
// Compiler pipeline (spec validation, normalization, generic compile)
|
|
42
55
|
// ---------------------------------------------------------------------------
|
|
@@ -100,5 +113,7 @@ export type {
|
|
|
100
113
|
SankeySpec,
|
|
101
114
|
TableLayout,
|
|
102
115
|
TableSpec,
|
|
116
|
+
TileMapLayout,
|
|
117
|
+
TileMapSpec,
|
|
103
118
|
VizSpec,
|
|
104
119
|
} from '@opendata-ai/openchart-core';
|
package/src/layout/axes/ticks.ts
CHANGED
|
@@ -5,7 +5,12 @@
|
|
|
5
5
|
* not from the chart area. Density thinning lives in ./thinning.ts.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
AxisLabelDensity,
|
|
10
|
+
AxisTick,
|
|
11
|
+
DataRow,
|
|
12
|
+
MeasureTextFn,
|
|
13
|
+
} from '@opendata-ai/openchart-core';
|
|
9
14
|
import {
|
|
10
15
|
abbreviateNumber,
|
|
11
16
|
buildD3Formatter,
|
|
@@ -221,6 +226,7 @@ export function categoricalTicks(
|
|
|
221
226
|
fontSize?: number,
|
|
222
227
|
fontWeight?: number,
|
|
223
228
|
measureText?: MeasureTextFn,
|
|
229
|
+
subtitleContext?: { data: DataRow[]; fieldName: string; labelField: string },
|
|
224
230
|
): AxisTick[] {
|
|
225
231
|
const scale = resolvedScale.scale as D3CategoricalScale;
|
|
226
232
|
const domain: string[] = scale.domain();
|
|
@@ -275,6 +281,23 @@ export function categoricalTicks(
|
|
|
275
281
|
}
|
|
276
282
|
// vertical band scale (horizontal bar y-axis): always show all labels
|
|
277
283
|
|
|
284
|
+
let subtitleMap: Map<string, string> | undefined;
|
|
285
|
+
if (subtitleContext) {
|
|
286
|
+
const { data, fieldName, labelField } = subtitleContext;
|
|
287
|
+
if (data.length > 0) {
|
|
288
|
+
subtitleMap = new Map();
|
|
289
|
+
for (const row of data) {
|
|
290
|
+
const key = String(row[fieldName] ?? '');
|
|
291
|
+
if (!subtitleMap.has(key)) {
|
|
292
|
+
const val = row[labelField];
|
|
293
|
+
if (val != null) {
|
|
294
|
+
subtitleMap.set(key, String(val));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
278
301
|
const ticks = selectedValues.map((value: string) => {
|
|
279
302
|
// Band scales: use the center of the band
|
|
280
303
|
const bandScale = resolvedScale.type === 'band' ? (scale as ScaleBand<string>) : null;
|
|
@@ -282,11 +305,20 @@ export function categoricalTicks(
|
|
|
282
305
|
? (bandScale(value) ?? 0) + bandScale.bandwidth() / 2
|
|
283
306
|
: ((scale(value) as number | undefined) ?? 0);
|
|
284
307
|
|
|
285
|
-
|
|
308
|
+
const tick: AxisTick = {
|
|
286
309
|
value,
|
|
287
310
|
position: pos,
|
|
288
311
|
label: value,
|
|
289
312
|
};
|
|
313
|
+
|
|
314
|
+
if (subtitleMap) {
|
|
315
|
+
const subtitle = subtitleMap.get(value);
|
|
316
|
+
if (subtitle !== undefined) {
|
|
317
|
+
tick.subtitle = subtitle;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return tick;
|
|
290
322
|
});
|
|
291
323
|
|
|
292
324
|
return ticks;
|
package/src/layout/axes.ts
CHANGED
|
@@ -10,6 +10,8 @@ import type {
|
|
|
10
10
|
AxisLabelDensity,
|
|
11
11
|
AxisLayout,
|
|
12
12
|
AxisTick,
|
|
13
|
+
DataRow,
|
|
14
|
+
Encoding,
|
|
13
15
|
Gridline,
|
|
14
16
|
LayoutStrategy,
|
|
15
17
|
MeasureTextFn,
|
|
@@ -191,6 +193,21 @@ export interface AxesResult {
|
|
|
191
193
|
y?: AxisLayout;
|
|
192
194
|
}
|
|
193
195
|
|
|
196
|
+
/** Optional data context for axis computation (enables labelField subtitles). */
|
|
197
|
+
export interface AxesDataContext {
|
|
198
|
+
/** The data rows for subtitle lookup. */
|
|
199
|
+
data: DataRow[];
|
|
200
|
+
/** The encoding object to resolve field names. */
|
|
201
|
+
encoding: Encoding;
|
|
202
|
+
/**
|
|
203
|
+
* When true, skip generating ticks/labels/title for the x-axis. Used by
|
|
204
|
+
* sparkline display mode when the user hasn't explicitly opted into axes.
|
|
205
|
+
*/
|
|
206
|
+
skipX?: boolean;
|
|
207
|
+
/** Same as skipX, for the y-axis. */
|
|
208
|
+
skipY?: boolean;
|
|
209
|
+
}
|
|
210
|
+
|
|
194
211
|
/**
|
|
195
212
|
* Compute axis layouts with tick positions, labels, and axis lines.
|
|
196
213
|
*
|
|
@@ -199,6 +216,7 @@ export interface AxesResult {
|
|
|
199
216
|
* @param strategy - Responsive layout strategy.
|
|
200
217
|
* @param theme - Resolved theme for styling.
|
|
201
218
|
* @param measureText - Optional real text measurement from the adapter.
|
|
219
|
+
* @param dataContext - Optional data context for labelField subtitle support.
|
|
202
220
|
*/
|
|
203
221
|
export function computeAxes(
|
|
204
222
|
scales: ResolvedScales,
|
|
@@ -206,6 +224,7 @@ export function computeAxes(
|
|
|
206
224
|
strategy: LayoutStrategy,
|
|
207
225
|
theme: ResolvedTheme,
|
|
208
226
|
measureText?: MeasureTextFn,
|
|
227
|
+
dataContext?: AxesDataContext,
|
|
209
228
|
): AxesResult {
|
|
210
229
|
const result: AxesResult = {};
|
|
211
230
|
const baseDensity = strategy.axisLabelDensity;
|
|
@@ -245,7 +264,7 @@ export function computeAxes(
|
|
|
245
264
|
const { fontSize } = tickLabelStyle;
|
|
246
265
|
const { fontWeight } = tickLabelStyle;
|
|
247
266
|
|
|
248
|
-
if (scales.x) {
|
|
267
|
+
if (scales.x && !dataContext?.skipX) {
|
|
249
268
|
const axisConfig = scales.x.channel.axis;
|
|
250
269
|
const isContinuousX =
|
|
251
270
|
scales.x.type !== 'band' && scales.x.type !== 'point' && scales.x.type !== 'ordinal';
|
|
@@ -347,7 +366,7 @@ export function computeAxes(
|
|
|
347
366
|
};
|
|
348
367
|
}
|
|
349
368
|
|
|
350
|
-
if (scales.y) {
|
|
369
|
+
if (scales.y && !dataContext?.skipY) {
|
|
351
370
|
const axisConfig = scales.y.channel.axis;
|
|
352
371
|
const isContinuousY =
|
|
353
372
|
scales.y.type !== 'band' && scales.y.type !== 'point' && scales.y.type !== 'ordinal';
|
|
@@ -362,7 +381,21 @@ export function computeAxes(
|
|
|
362
381
|
if (axisConfig?.values) {
|
|
363
382
|
allTicks = resolveExplicitTicks(axisConfig.values, scales.y);
|
|
364
383
|
} else if (!isContinuousY) {
|
|
365
|
-
|
|
384
|
+
const yFieldName = dataContext?.encoding.y?.field;
|
|
385
|
+
const yLabelField = axisConfig?.labelField;
|
|
386
|
+
allTicks = categoricalTicks(
|
|
387
|
+
scales.y,
|
|
388
|
+
yDensity,
|
|
389
|
+
'vertical',
|
|
390
|
+
undefined,
|
|
391
|
+
undefined,
|
|
392
|
+
undefined,
|
|
393
|
+
undefined,
|
|
394
|
+
undefined,
|
|
395
|
+
yFieldName && yLabelField && dataContext
|
|
396
|
+
? { data: dataContext.data, fieldName: yFieldName, labelField: yLabelField }
|
|
397
|
+
: undefined,
|
|
398
|
+
);
|
|
366
399
|
} else {
|
|
367
400
|
allTicks = continuousTicks(scales.y, yDensity, yTargetCount);
|
|
368
401
|
}
|
package/src/layout/dimensions.ts
CHANGED
|
@@ -93,6 +93,30 @@ function scalePadding(basePadding: number, width: number, height: number): numbe
|
|
|
93
93
|
const MIN_CHART_WIDTH = 60;
|
|
94
94
|
const MIN_CHART_HEIGHT = 40;
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Per-display minimum chart dimensions. Sparkline mode allows much smaller
|
|
98
|
+
* containers (down to ~30x20px) since the entire chart is just the mark.
|
|
99
|
+
*/
|
|
100
|
+
function getMinChartDims(display: import('@opendata-ai/openchart-core').Display): {
|
|
101
|
+
width: number;
|
|
102
|
+
height: number;
|
|
103
|
+
} {
|
|
104
|
+
return display === 'sparkline'
|
|
105
|
+
? { width: 30, height: 20 }
|
|
106
|
+
: { width: MIN_CHART_WIDTH, height: MIN_CHART_HEIGHT };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve the per-side safety padding for sparkline mode. Padding scales with
|
|
111
|
+
* the user-configured mark stroke width so a thick line doesn't clip at the
|
|
112
|
+
* container edge. Per-side padding = max(strokeWidth/2 + 1, 2) so even a 1px
|
|
113
|
+
* stroke gets at least 2px breathing room.
|
|
114
|
+
*/
|
|
115
|
+
function getSparklinePad(spec: NormalizedChartSpec): number {
|
|
116
|
+
const strokeWidth = (spec.markDef as { strokeWidth?: number }).strokeWidth ?? 2;
|
|
117
|
+
return Math.max(strokeWidth / 2 + 1, 2);
|
|
118
|
+
}
|
|
119
|
+
|
|
96
120
|
// ---------------------------------------------------------------------------
|
|
97
121
|
// Public API
|
|
98
122
|
// ---------------------------------------------------------------------------
|
|
@@ -125,7 +149,16 @@ export function computeDimensions(
|
|
|
125
149
|
? Math.max(Math.round(padding * HPAD_COMPACT_FRACTION), HPAD_COMPACT_MIN)
|
|
126
150
|
: padding;
|
|
127
151
|
const axisMargin = theme.spacing.axisMargin;
|
|
128
|
-
const
|
|
152
|
+
const userExplicit = spec.userExplicit;
|
|
153
|
+
const isSparkline = spec.display === 'sparkline';
|
|
154
|
+
|
|
155
|
+
// Sparkline mode forces chrome hidden unless the user opted in explicitly.
|
|
156
|
+
// Force-hiding chrome here also short-circuits the watermark (which is
|
|
157
|
+
// rendered as part of chrome), so we don't need a separate watermark gate.
|
|
158
|
+
let chromeMode = strategy?.chromeMode ?? 'full';
|
|
159
|
+
if (isSparkline && !userExplicit.chrome) {
|
|
160
|
+
chromeMode = 'hidden';
|
|
161
|
+
}
|
|
129
162
|
|
|
130
163
|
// Compute chrome with mode and scaled padding
|
|
131
164
|
const chrome = computeChrome(
|
|
@@ -138,6 +171,47 @@ export function computeDimensions(
|
|
|
138
171
|
watermark,
|
|
139
172
|
);
|
|
140
173
|
|
|
174
|
+
// Sparkline mode: produce a near-edge-to-edge layout. Only stroke-width-based
|
|
175
|
+
// safety padding plus chrome (if user-explicit). Skip axis space, label
|
|
176
|
+
// reservations, annotation reservations, and legend reservations unless the
|
|
177
|
+
// user opted in to those individually.
|
|
178
|
+
if (isSparkline) {
|
|
179
|
+
const total: Rect = { x: 0, y: 0, width, height };
|
|
180
|
+
const sparkPad = getSparklinePad(spec);
|
|
181
|
+
|
|
182
|
+
// Axis space only when user explicitly set encoding.x/y.axis.
|
|
183
|
+
const xAxisSpace = userExplicit.xAxis ? 26 : 0;
|
|
184
|
+
const yAxisSpace = userExplicit.yAxis ? 30 : 0;
|
|
185
|
+
|
|
186
|
+
const margins: Margins = {
|
|
187
|
+
top: chrome.topHeight + sparkPad,
|
|
188
|
+
right: sparkPad,
|
|
189
|
+
bottom: chrome.bottomHeight + sparkPad + xAxisSpace,
|
|
190
|
+
left: sparkPad + yAxisSpace,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// Reserve legend space only when user explicitly opted into a legend.
|
|
194
|
+
if (userExplicit.legend && 'entries' in legendLayout && legendLayout.entries.length > 0) {
|
|
195
|
+
const gap = legendGap(width);
|
|
196
|
+
if (legendLayout.position === 'right' || legendLayout.position === 'bottom-right') {
|
|
197
|
+
margins.right += legendLayout.bounds.width + 8;
|
|
198
|
+
} else if (legendLayout.position === 'top') {
|
|
199
|
+
margins.top += legendLayout.bounds.height + gap;
|
|
200
|
+
} else if (legendLayout.position === 'bottom') {
|
|
201
|
+
margins.bottom += legendLayout.bounds.height + gap;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const chartArea: Rect = {
|
|
206
|
+
x: margins.left,
|
|
207
|
+
y: margins.top,
|
|
208
|
+
width: Math.max(0, width - margins.left - margins.right),
|
|
209
|
+
height: Math.max(0, height - margins.top - margins.bottom),
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
return { total, chrome, chartArea, margins, theme };
|
|
213
|
+
}
|
|
214
|
+
|
|
141
215
|
// Start with the total rect
|
|
142
216
|
const total: Rect = { x: 0, y: 0, width, height };
|
|
143
217
|
|
|
@@ -275,10 +349,26 @@ export function computeDimensions(
|
|
|
275
349
|
) {
|
|
276
350
|
// Category labels on the left for bar/dot charts
|
|
277
351
|
const yField = encoding.y.field;
|
|
352
|
+
const yLabelField = (encoding.y.axis as Record<string, unknown> | undefined)?.labelField as
|
|
353
|
+
| string
|
|
354
|
+
| undefined;
|
|
278
355
|
let maxLabelWidth = 0;
|
|
279
356
|
for (const row of spec.data) {
|
|
280
357
|
const label = String(row[yField] ?? '');
|
|
281
|
-
|
|
358
|
+
let w = estimateTextWidth(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
|
|
359
|
+
// When labelField is set, add a gap and the subtitle width
|
|
360
|
+
if (yLabelField) {
|
|
361
|
+
const subtitle = String(row[yLabelField] ?? '');
|
|
362
|
+
if (subtitle) {
|
|
363
|
+
const gap = theme.fonts.sizes.axisTick * 0.6;
|
|
364
|
+
const subtitleWidth = estimateTextWidth(
|
|
365
|
+
subtitle,
|
|
366
|
+
theme.fonts.sizes.axisTick,
|
|
367
|
+
theme.fonts.weights.normal,
|
|
368
|
+
);
|
|
369
|
+
w += gap + subtitleWidth;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
282
372
|
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
283
373
|
}
|
|
284
374
|
if (maxLabelWidth > 0) {
|
|
@@ -359,7 +449,7 @@ export function computeDimensions(
|
|
|
359
449
|
}
|
|
360
450
|
|
|
361
451
|
// Reserve legend space
|
|
362
|
-
if (legendLayout.entries.length > 0) {
|
|
452
|
+
if ('entries' in legendLayout && legendLayout.entries.length > 0) {
|
|
363
453
|
const gap = legendGap(width);
|
|
364
454
|
if (legendLayout.position === 'right' || legendLayout.position === 'bottom-right') {
|
|
365
455
|
margins.right += legendLayout.bounds.width + 8;
|
|
@@ -379,8 +469,9 @@ export function computeDimensions(
|
|
|
379
469
|
};
|
|
380
470
|
|
|
381
471
|
// Guardrail: if chart area is too small, progressively strip chrome
|
|
472
|
+
const minDims = getMinChartDims(spec.display);
|
|
382
473
|
if (
|
|
383
|
-
(chartArea.width <
|
|
474
|
+
(chartArea.width < minDims.width || chartArea.height < minDims.height) &&
|
|
384
475
|
chromeMode !== 'hidden'
|
|
385
476
|
) {
|
|
386
477
|
// Try compact first, then hidden
|
|
@@ -407,7 +498,9 @@ export function computeDimensions(
|
|
|
407
498
|
const gap = legendGap(width);
|
|
408
499
|
margins.top =
|
|
409
500
|
newTop +
|
|
410
|
-
(
|
|
501
|
+
('entries' in legendLayout &&
|
|
502
|
+
legendLayout.entries.length > 0 &&
|
|
503
|
+
legendLayout.position === 'top'
|
|
411
504
|
? legendLayout.bounds.height + gap
|
|
412
505
|
: 0);
|
|
413
506
|
margins.bottom = newBottom;
|
package/src/legend/compute.ts
CHANGED
|
@@ -143,8 +143,13 @@ export function computeLegend(
|
|
|
143
143
|
chartArea: Rect,
|
|
144
144
|
watermark: boolean = true,
|
|
145
145
|
): LegendLayout {
|
|
146
|
+
// Sparkline mode: legend hidden by default unless the user opted in. Color
|
|
147
|
+
// scales still resolve normally (legend hidden != no colors), so multi-series
|
|
148
|
+
// sparklines retain their categorical palette.
|
|
149
|
+
const sparklineHidden = spec.display === 'sparkline' && !spec.userExplicit.legend;
|
|
150
|
+
|
|
146
151
|
// Legend explicitly hidden via show: false, or height strategy says no legend
|
|
147
|
-
if (spec.legend?.show === false || strategy.legendMaxHeight === 0) {
|
|
152
|
+
if (sparklineHidden || spec.legend?.show === false || strategy.legendMaxHeight === 0) {
|
|
148
153
|
return {
|
|
149
154
|
position: 'top',
|
|
150
155
|
entries: [],
|
|
@@ -312,7 +312,7 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
|
|
|
312
312
|
);
|
|
313
313
|
|
|
314
314
|
// Reserve legend space by shrinking the drawing area
|
|
315
|
-
const legendGap = legend.entries.length > 0 ? 4 : 0;
|
|
315
|
+
const legendGap = 'entries' in legend && legend.entries.length > 0 ? 4 : 0;
|
|
316
316
|
const area: Rect = {
|
|
317
317
|
x: fullArea.x,
|
|
318
318
|
y: fullArea.y + legend.bounds.height + legendGap,
|