@opendata-ai/openchart-engine 7.10.0 → 8.0.0-rc.1
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 +142 -7
- package/dist/index.js +16182 -10032
- package/dist/index.js.map +1 -1
- package/package.json +11 -4
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +170 -56
- package/src/__tests__/annotation-behavior.test.ts +721 -0
- package/src/__tests__/axes.test.ts +50 -3
- package/src/__tests__/compile-chart.test.ts +30 -0
- package/src/__tests__/compile-footnotes.test.ts +277 -0
- package/src/__tests__/compile-layer.test.ts +120 -1
- package/src/__tests__/dimensions.test.ts +49 -4
- package/src/__tests__/helpers/invariants.ts +54 -20
- package/src/__tests__/known-bugs.test.ts +455 -2
- package/src/__tests__/legend.test.ts +120 -4
- package/src/__tests__/ranking-recipes.test.ts +260 -0
- package/src/__tests__/scales.test.ts +9 -3
- package/src/__tests__/series-search.test.ts +92 -0
- package/src/__tests__/spec-sugar.test.ts +801 -0
- package/src/__tests__/you-draw-it.test.ts +160 -0
- package/src/annotations/__tests__/compute.test.ts +207 -92
- package/src/annotations/__tests__/geometry.test.ts +66 -0
- package/src/annotations/__tests__/resolve-text.test.ts +527 -27
- package/src/annotations/__tests__/rich-text.test.ts +91 -0
- package/src/annotations/__tests__/thinning.test.ts +148 -0
- package/src/annotations/collisions.ts +130 -84
- package/src/annotations/compute.ts +83 -85
- package/src/annotations/constants.ts +116 -6
- package/src/annotations/geometry.ts +222 -77
- package/src/annotations/move.ts +135 -0
- package/src/annotations/placement.ts +62 -14
- package/src/annotations/resolve-range.ts +2 -0
- package/src/annotations/resolve-refline.ts +2 -0
- package/src/annotations/resolve-text.ts +312 -111
- package/src/annotations/rich-text.ts +65 -0
- package/src/annotations/thinning.ts +84 -10
- package/src/barlist/compile-barlist.ts +16 -6
- package/src/charts/_shared/format-label-value.ts +9 -8
- package/src/charts/bar/__tests__/compute.test.ts +57 -10
- package/src/charts/bar/__tests__/labels.test.ts +5 -4
- package/src/charts/bar/compute.ts +2 -6
- package/src/charts/bar/index.ts +8 -1
- package/src/charts/bar/labels.ts +3 -3
- package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
- package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
- package/src/charts/beeswarm/compute.ts +203 -0
- package/src/charts/beeswarm/dodge.ts +155 -0
- package/src/charts/beeswarm/index.ts +32 -0
- package/src/charts/builtin.ts +12 -1
- package/src/charts/calendar/__tests__/compute.test.ts +417 -0
- package/src/charts/calendar/compute.ts +319 -0
- package/src/charts/calendar/index.ts +32 -0
- package/src/charts/column/__tests__/compute.test.ts +21 -7
- package/src/charts/column/__tests__/labels.test.ts +76 -1
- package/src/charts/column/compute.ts +2 -6
- package/src/charts/column/index.ts +10 -2
- package/src/charts/column/labels.ts +95 -21
- package/src/charts/dot/index.ts +8 -1
- package/src/charts/dot/labels.ts +3 -3
- package/src/charts/line/__tests__/compute.test.ts +15 -19
- package/src/charts/line/area.ts +8 -23
- package/src/charts/line/compute.ts +32 -2
- package/src/charts/parliament/__tests__/compute.test.ts +411 -0
- package/src/charts/parliament/compute.ts +313 -0
- package/src/charts/parliament/index.ts +32 -0
- package/src/charts/pie/__tests__/compute.test.ts +103 -0
- package/src/charts/pie/compute.ts +63 -8
- package/src/charts/pie/labels.ts +1 -0
- package/src/charts/range/__tests__/compute.test.ts +486 -0
- package/src/charts/range/compute.ts +273 -0
- package/src/charts/range/index.ts +82 -0
- package/src/charts/range/labels.ts +169 -0
- package/src/charts/rect/__tests__/compute.test.ts +120 -0
- package/src/charts/rect/index.ts +127 -0
- package/src/charts/scatter/compute.ts +7 -21
- package/src/charts/text/__tests__/compute.test.ts +307 -0
- package/src/charts/text/index.ts +44 -7
- package/src/charts/tick/__tests__/compute.test.ts +75 -0
- package/src/charts/tick/index.ts +7 -4
- package/src/charts/utils.ts +5 -3
- package/src/charts/waffle/__tests__/compute.test.ts +305 -0
- package/src/charts/waffle/compute.ts +197 -0
- package/src/charts/waffle/index.ts +31 -0
- package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
- package/src/compile/__tests__/data-clip.test.ts +68 -0
- package/src/compile/__tests__/fill-patterns.test.ts +154 -0
- package/src/compile/__tests__/size-scale.test.ts +108 -0
- package/src/compile/color-scale-range.ts +54 -33
- package/src/compile/data-clip.ts +21 -6
- package/src/compile/fill-patterns.ts +74 -0
- package/src/compile/layer.ts +117 -21
- package/src/compile/size-scale.ts +139 -0
- package/src/compile/spec-sugar.ts +758 -0
- package/src/compile/you-draw-it.ts +123 -0
- package/src/compile.ts +315 -103
- package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
- package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
- package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
- package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
- package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
- package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
- package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
- package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
- package/src/compiler/__tests__/normalize.test.ts +65 -1
- package/src/compiler/__tests__/validate-did-you-mean.test.ts +86 -0
- package/src/compiler/__tests__/validate-near-miss.test.ts +89 -0
- package/src/compiler/__tests__/validate.test.ts +156 -0
- package/src/compiler/__tests__/vl-parity.test.ts +68 -0
- package/src/compiler/a11y-warnings.ts +110 -0
- package/src/compiler/normalize.ts +150 -7
- package/src/compiler/types.ts +11 -0
- package/src/compiler/validate.ts +664 -18
- package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
- package/src/endpoint-labels/compute.ts +40 -12
- package/src/endpoint-labels/format.ts +45 -15
- package/src/endpoint-labels/predict.ts +50 -36
- package/src/format/__tests__/field-format.test.ts +164 -0
- package/src/format/field-format.ts +27 -0
- package/src/geo/__tests__/compile-map.test.ts +621 -0
- package/src/geo/compile-map.ts +856 -0
- package/src/geo/join.ts +115 -0
- package/src/geo/projections.ts +45 -0
- package/src/geo/types.ts +24 -0
- package/src/graphs/__tests__/compile-graph.test.ts +25 -0
- package/src/graphs/compile-graph.ts +27 -18
- package/src/index.ts +28 -2
- package/src/layout/axes/ticks.ts +59 -10
- package/src/layout/axes.ts +30 -2
- package/src/layout/dimensions.ts +178 -95
- package/src/layout/facet.ts +14 -3
- package/src/layout/plan.ts +86 -35
- package/src/layout/scales.ts +132 -15
- package/src/legend/__tests__/continuous.test.ts +318 -0
- package/src/legend/__tests__/size.test.ts +348 -0
- package/src/legend/__tests__/suppression.test.ts +17 -1
- package/src/legend/compute.ts +126 -14
- package/src/legend/continuous.ts +327 -0
- package/src/legend/size.ts +407 -0
- package/src/legend/suppression.ts +8 -7
- package/src/legend/wrap.ts +8 -0
- package/src/sankey/compile-sankey.ts +31 -19
- package/src/tables/format-cells.ts +16 -7
- package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
- package/src/tilemap/compile-tilemap.ts +24 -7
- package/src/tooltips/__tests__/compute.test.ts +2 -2
- package/src/tooltips/compute.ts +422 -41
- package/src/transforms/__tests__/conditional.test.ts +18 -0
- package/src/transforms/aggregate.ts +2 -1
package/src/geo/join.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { CompileWarning } from '@opendata-ai/openchart-core';
|
|
2
|
+
|
|
3
|
+
export function normalizeFips(value: string | number, digits: 2 | 5 = 2): string {
|
|
4
|
+
return String(value).padStart(digits, '0');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function detectKeyFormat(keys: string[]): string | null {
|
|
8
|
+
if (keys.length === 0) return null;
|
|
9
|
+
const allNumeric = keys.every((k) => /^\d+$/.test(k));
|
|
10
|
+
if (allNumeric) {
|
|
11
|
+
const maxLen = Math.max(...keys.map((k) => k.length));
|
|
12
|
+
if (maxLen <= 2) return 'state-fips';
|
|
13
|
+
if (maxLen <= 3) return 'iso-numeric';
|
|
14
|
+
if (maxLen <= 5) return 'county-fips';
|
|
15
|
+
}
|
|
16
|
+
if (keys.every((k) => /^[A-Z]{2}$/.test(k))) return 'iso-alpha2';
|
|
17
|
+
if (keys.every((k) => /^[A-Z]{3}$/.test(k))) return 'iso-alpha3';
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function suggestNormalization(unmatchedKeys: string[], featureIds: string[]): string | null {
|
|
22
|
+
const dataFormat = detectKeyFormat(unmatchedKeys);
|
|
23
|
+
const featureFormat = detectKeyFormat(featureIds);
|
|
24
|
+
|
|
25
|
+
if (dataFormat === 'state-fips' && featureFormat === 'state-fips') {
|
|
26
|
+
const needsPadding = unmatchedKeys.some((k) => k.length === 1);
|
|
27
|
+
if (needsPadding) return 'Data keys appear to need zero-padding (e.g. "1" -> "01").';
|
|
28
|
+
}
|
|
29
|
+
if (dataFormat === 'county-fips') {
|
|
30
|
+
const needsPadding = unmatchedKeys.some((k) => k.length < 5);
|
|
31
|
+
if (needsPadding)
|
|
32
|
+
return 'County FIPS codes should be 5 digits with zero-padding (e.g. "1001" -> "01001").';
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface JoinResult {
|
|
38
|
+
joined: Map<string | number, Record<string, unknown>>;
|
|
39
|
+
warnings: CompileWarning[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function joinDataToFeatures(
|
|
43
|
+
features: Array<{ id: string | number; properties: Record<string, unknown> }>,
|
|
44
|
+
data: Record<string, unknown>[],
|
|
45
|
+
dataKeyField: string,
|
|
46
|
+
featureIdField: string,
|
|
47
|
+
): JoinResult {
|
|
48
|
+
const warnings: CompileWarning[] = [];
|
|
49
|
+
|
|
50
|
+
const dataLookup = new Map<string, Record<string, unknown>>();
|
|
51
|
+
for (const row of data) {
|
|
52
|
+
const key = String(row[dataKeyField]);
|
|
53
|
+
dataLookup.set(key, row);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const joined = new Map<string | number, Record<string, unknown>>();
|
|
57
|
+
const usedDataKeys = new Set<string>();
|
|
58
|
+
let lowerLookup: Map<string, Record<string, unknown>> | null = null;
|
|
59
|
+
let unmatchedFeatureCount = 0;
|
|
60
|
+
|
|
61
|
+
for (const feature of features) {
|
|
62
|
+
const featureId =
|
|
63
|
+
featureIdField === 'id'
|
|
64
|
+
? String(feature.id)
|
|
65
|
+
: String(feature.properties[featureIdField] ?? feature.id);
|
|
66
|
+
|
|
67
|
+
let match = dataLookup.get(featureId);
|
|
68
|
+
|
|
69
|
+
// Try case-insensitive fallback for name-based joins
|
|
70
|
+
if (!match) {
|
|
71
|
+
if (!lowerLookup) {
|
|
72
|
+
lowerLookup = new Map<string, Record<string, unknown>>();
|
|
73
|
+
for (const [key, row] of dataLookup) {
|
|
74
|
+
lowerLookup.set(key.toLowerCase(), row);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
match = lowerLookup.get(featureId.toLowerCase());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (match) {
|
|
81
|
+
joined.set(feature.id, match);
|
|
82
|
+
usedDataKeys.add(String(match[dataKeyField]));
|
|
83
|
+
} else {
|
|
84
|
+
unmatchedFeatureCount++;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const unmatchedDataKeys: string[] = [];
|
|
89
|
+
for (const key of dataLookup.keys()) {
|
|
90
|
+
if (!usedDataKeys.has(key)) {
|
|
91
|
+
unmatchedDataKeys.push(key);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (unmatchedDataKeys.length > 0) {
|
|
96
|
+
const sampleKeys = unmatchedDataKeys.slice(0, 5);
|
|
97
|
+
const sampleFeatureIds = features.slice(0, 3).map((f) => String(f.id));
|
|
98
|
+
const suggestion = suggestNormalization(unmatchedDataKeys, sampleFeatureIds);
|
|
99
|
+
warnings.push({
|
|
100
|
+
code: 'UNMATCHED_DATA_KEYS',
|
|
101
|
+
message: `${unmatchedDataKeys.length} data row(s) matched no geo feature. Keys: ${sampleKeys.join(', ')}${unmatchedDataKeys.length > 5 ? ` (and ${unmatchedDataKeys.length - 5} more)` : ''}.${suggestion ? ` ${suggestion}` : ''} Expected format matches feature IDs like: ${sampleFeatureIds.join(', ')}.`,
|
|
102
|
+
context: { keys: unmatchedDataKeys },
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (unmatchedFeatureCount > 0) {
|
|
107
|
+
warnings.push({
|
|
108
|
+
code: 'UNMATCHED_FEATURES',
|
|
109
|
+
message: `${unmatchedFeatureCount} geo feature(s) have no matching data and will use neutral fill.`,
|
|
110
|
+
context: { count: unmatchedFeatureCount },
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { joined, warnings };
|
|
115
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { MapProjection } from '@opendata-ai/openchart-core';
|
|
2
|
+
import type { GeoIdentityTransform, GeoPermissibleObjects, GeoProjection } from 'd3-geo';
|
|
3
|
+
import { geoAlbersUsa, geoBounds, geoEqualEarth, geoIdentity, geoMercator } from 'd3-geo';
|
|
4
|
+
|
|
5
|
+
// Mercator inflates polar regions toward infinity. Fitting against features
|
|
6
|
+
// entirely below this latitude (Antarctica) wastes most of the map area on
|
|
7
|
+
// empty ocean. Exclude them from the fit; they still render if in view.
|
|
8
|
+
const MERCATOR_FIT_MIN_LAT = -60;
|
|
9
|
+
|
|
10
|
+
export function createProjection(
|
|
11
|
+
type: MapProjection,
|
|
12
|
+
width: number,
|
|
13
|
+
height: number,
|
|
14
|
+
geojson: GeoPermissibleObjects,
|
|
15
|
+
): GeoProjection | GeoIdentityTransform {
|
|
16
|
+
if (type === 'identity') {
|
|
17
|
+
return geoIdentity().fitSize([width, height], geojson);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const projection =
|
|
21
|
+
type === 'albersUsa' ? geoAlbersUsa() : type === 'equalEarth' ? geoEqualEarth() : geoMercator();
|
|
22
|
+
|
|
23
|
+
const fitGeometry = type === 'mercator' ? excludePolarFeatures(geojson) : geojson;
|
|
24
|
+
projection.fitSize([width, height], fitGeometry);
|
|
25
|
+
return projection;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function excludePolarFeatures(geojson: GeoPermissibleObjects): GeoPermissibleObjects {
|
|
29
|
+
// GeoPermissibleObjects doesn't include FeatureCollection in its type union,
|
|
30
|
+
// but topojson.feature() returns one at runtime. Work with the runtime shape.
|
|
31
|
+
const obj = geojson as unknown as Record<string, unknown>;
|
|
32
|
+
if (obj.type !== 'FeatureCollection' || !Array.isArray(obj.features)) return geojson;
|
|
33
|
+
|
|
34
|
+
const features = obj.features as Array<Record<string, unknown>>;
|
|
35
|
+
const filtered = features.filter((f) => {
|
|
36
|
+
try {
|
|
37
|
+
const b = geoBounds(f as unknown as GeoPermissibleObjects);
|
|
38
|
+
return b[1][1] > MERCATOR_FIT_MIN_LAT;
|
|
39
|
+
} catch {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
if (filtered.length === 0) return geojson;
|
|
44
|
+
return { type: 'FeatureCollection', features: filtered } as unknown as GeoPermissibleObjects;
|
|
45
|
+
}
|
package/src/geo/types.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AnimationSpec,
|
|
3
|
+
DarkMode,
|
|
4
|
+
LegendConfig,
|
|
5
|
+
MapEncoding,
|
|
6
|
+
MapGeo,
|
|
7
|
+
ThemeConfig,
|
|
8
|
+
} from '@opendata-ai/openchart-core';
|
|
9
|
+
|
|
10
|
+
import type { NormalizedChrome } from '../compiler/types';
|
|
11
|
+
|
|
12
|
+
export interface NormalizedMapSpec {
|
|
13
|
+
type: 'map';
|
|
14
|
+
geo: Required<MapGeo>;
|
|
15
|
+
data: Record<string, unknown>[];
|
|
16
|
+
encoding: MapEncoding;
|
|
17
|
+
chrome: NormalizedChrome;
|
|
18
|
+
legend?: LegendConfig;
|
|
19
|
+
theme: ThemeConfig;
|
|
20
|
+
darkMode: DarkMode;
|
|
21
|
+
watermark: boolean;
|
|
22
|
+
animation?: AnimationSpec;
|
|
23
|
+
valueFormat?: string;
|
|
24
|
+
}
|
|
@@ -228,6 +228,31 @@ describe('compileGraph', () => {
|
|
|
228
228
|
});
|
|
229
229
|
});
|
|
230
230
|
|
|
231
|
+
describe('legend', () => {
|
|
232
|
+
it('is empty when there is no color encoding and no clustering', () => {
|
|
233
|
+
// Every node shares one fill, so a legend would list each node label
|
|
234
|
+
// against an identical swatch and communicate nothing.
|
|
235
|
+
const result = compileGraph(makeBasicGraphSpec(), compileOptions);
|
|
236
|
+
|
|
237
|
+
expect(result.legend.entries).toEqual([]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('entries come from the nodeColor field, not node labels', () => {
|
|
241
|
+
const result = compileGraph(makeEncodedGraphSpec(), compileOptions);
|
|
242
|
+
|
|
243
|
+
const labels = result.legend.entries.map((e) => e.label).sort();
|
|
244
|
+
expect(labels).toEqual(['X', 'Y']);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('is empty when the nodeColor field has only one category', () => {
|
|
248
|
+
const spec = makeEncodedGraphSpec();
|
|
249
|
+
for (const node of spec.nodes) node.group = 'X';
|
|
250
|
+
const result = compileGraph(spec, compileOptions);
|
|
251
|
+
|
|
252
|
+
expect(result.legend.entries).toEqual([]);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
231
256
|
describe('tooltips', () => {
|
|
232
257
|
it('generates tooltip descriptors for each node', () => {
|
|
233
258
|
const result = compileGraph(makeBasicGraphSpec(), compileOptions);
|
|
@@ -22,14 +22,21 @@ import type {
|
|
|
22
22
|
TooltipContent,
|
|
23
23
|
TooltipField,
|
|
24
24
|
} from '@opendata-ai/openchart-core';
|
|
25
|
-
import {
|
|
26
|
-
|
|
25
|
+
import {
|
|
26
|
+
adaptTheme,
|
|
27
|
+
computeChrome,
|
|
28
|
+
defaultNumberFormatter,
|
|
29
|
+
resolveTheme,
|
|
30
|
+
} from '@opendata-ai/openchart-core';
|
|
31
|
+
import { emitSpecWarnings } from '../compile/spec-sugar';
|
|
27
32
|
import { compile as compileSpec } from '../compiler/index';
|
|
28
33
|
import type { NormalizedGraphSpec } from '../compiler/types';
|
|
29
34
|
import { applyCommunityColors, assignCommunities, buildCommunityColorMap } from './community';
|
|
30
35
|
import { resolveEdgeVisuals, resolveNodeVisuals } from './encoding';
|
|
31
36
|
import type { CompiledGraphNode, GraphCompilation, SimulationConfig } from './types';
|
|
32
37
|
|
|
38
|
+
const graphNumberFormatter = defaultNumberFormatter({ allIntegers: false, surface: 'chart' });
|
|
39
|
+
|
|
33
40
|
// ---------------------------------------------------------------------------
|
|
34
41
|
// Constants
|
|
35
42
|
// ---------------------------------------------------------------------------
|
|
@@ -74,31 +81,32 @@ function buildGraphLegend(
|
|
|
74
81
|
shape: 'circle' as const,
|
|
75
82
|
active: true,
|
|
76
83
|
}));
|
|
77
|
-
} else {
|
|
84
|
+
} else if (nodeColorField) {
|
|
78
85
|
// Build legend from nodeColor encoding: group by the color field value
|
|
79
86
|
// so each legend entry shows the categorical value (e.g. "Dataset", "bls")
|
|
80
87
|
// rather than an arbitrary node label.
|
|
81
88
|
const categoryColors = new Map<string, string>();
|
|
82
89
|
for (const node of nodes) {
|
|
83
|
-
const category = nodeColorField
|
|
84
|
-
? String(node.data[nodeColorField] ?? node.label ?? node.id)
|
|
85
|
-
: (node.label ?? node.id);
|
|
90
|
+
const category = String(node.data[nodeColorField] ?? node.label ?? node.id);
|
|
86
91
|
if (!categoryColors.has(category)) {
|
|
87
92
|
categoryColors.set(category, node.fill);
|
|
88
93
|
}
|
|
89
94
|
}
|
|
90
95
|
|
|
91
96
|
// Only show legend if there are multiple categories
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
97
|
+
entries =
|
|
98
|
+
categoryColors.size <= 1
|
|
99
|
+
? []
|
|
100
|
+
: [...categoryColors.entries()].map(([label, color]) => ({
|
|
101
|
+
label,
|
|
102
|
+
color,
|
|
103
|
+
shape: 'circle' as const,
|
|
104
|
+
active: true,
|
|
105
|
+
}));
|
|
106
|
+
} else {
|
|
107
|
+
// No communities and no color encoding: every node shares one fill, so a
|
|
108
|
+
// legend would just list every node label against identical swatches.
|
|
109
|
+
entries = [];
|
|
102
110
|
}
|
|
103
111
|
|
|
104
112
|
return {
|
|
@@ -144,7 +152,7 @@ function buildGraphTooltips(nodes: CompiledGraphNode[]): Map<string, TooltipCont
|
|
|
144
152
|
|
|
145
153
|
fields.push({
|
|
146
154
|
label: key,
|
|
147
|
-
value: typeof value === 'number' ? value
|
|
155
|
+
value: typeof value === 'number' ? graphNumberFormatter(value) : String(value),
|
|
148
156
|
});
|
|
149
157
|
}
|
|
150
158
|
|
|
@@ -185,7 +193,8 @@ function buildGraphTooltips(nodes: CompiledGraphNode[]): Map<string, TooltipCont
|
|
|
185
193
|
*/
|
|
186
194
|
export function compileGraph(spec: unknown, options: CompileOptions): GraphCompilation {
|
|
187
195
|
// 1. Validate + normalize
|
|
188
|
-
const { spec: normalized } = compileSpec(spec);
|
|
196
|
+
const { spec: normalized, warnings } = compileSpec(spec);
|
|
197
|
+
emitSpecWarnings(warnings, options.onWarn);
|
|
189
198
|
|
|
190
199
|
if (!('type' in normalized) || normalized.type !== 'graph') {
|
|
191
200
|
throw new Error(
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ export {
|
|
|
17
17
|
compileChart,
|
|
18
18
|
compileGraph,
|
|
19
19
|
compileLayer,
|
|
20
|
+
compileMap,
|
|
20
21
|
compileSankey,
|
|
21
22
|
compileTable,
|
|
22
23
|
compileTileMap,
|
|
@@ -69,6 +70,8 @@ export type { NormalizedSankeySpec } from './sankey/types';
|
|
|
69
70
|
|
|
70
71
|
export type { NormalizedBarListSpec } from './barlist/types';
|
|
71
72
|
|
|
73
|
+
export type { NormalizedMapSpec } from './geo/types';
|
|
74
|
+
|
|
72
75
|
export type { NormalizedTileMapSpec } from './tilemap/types';
|
|
73
76
|
|
|
74
77
|
// ---------------------------------------------------------------------------
|
|
@@ -76,9 +79,19 @@ export type { NormalizedTileMapSpec } from './tilemap/types';
|
|
|
76
79
|
// ---------------------------------------------------------------------------
|
|
77
80
|
|
|
78
81
|
export type { AnnotationContext } from './annotations/compute';
|
|
79
|
-
export
|
|
80
|
-
|
|
82
|
+
export {
|
|
83
|
+
ARROWHEAD_LENGTH,
|
|
84
|
+
DEFAULT_ANNOTATION_FONT_SIZE,
|
|
85
|
+
DEFAULT_LINE_HEIGHT,
|
|
86
|
+
} from './annotations/constants';
|
|
87
|
+
export type { AnnotationMeasureTextFn, ArrowheadPoints } from './annotations/geometry';
|
|
88
|
+
export {
|
|
89
|
+
computeArrowheadPoints,
|
|
90
|
+
heuristicMeasure,
|
|
91
|
+
} from './annotations/geometry';
|
|
81
92
|
export type { ObstacleKind, PlacementObstacle } from './annotations/placement';
|
|
93
|
+
export type { RichSpan } from './annotations/rich-text';
|
|
94
|
+
export { BOLD_SPAN_FONT_WEIGHT, parseAnnotationSpans } from './annotations/rich-text';
|
|
82
95
|
|
|
83
96
|
// ---------------------------------------------------------------------------
|
|
84
97
|
// Compiler pipeline (spec validation, normalization, generic compile)
|
|
@@ -112,6 +125,17 @@ export {
|
|
|
112
125
|
registerChartRenderer,
|
|
113
126
|
} from './charts/registry';
|
|
114
127
|
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Legend geometry
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
// Used by the vanilla mount to compute facet height growth for auto-height
|
|
133
|
+
// figures; single-sourcing avoids constant divergence with facet.ts.
|
|
134
|
+
export { facetMinHeight } from './layout/facet';
|
|
135
|
+
// Used by the vanilla mount to compute the top-legend block when growing
|
|
136
|
+
// auto-height figures; exporting it keeps the gap math single-sourced.
|
|
137
|
+
export { legendGap } from './legend/wrap';
|
|
138
|
+
|
|
115
139
|
// ---------------------------------------------------------------------------
|
|
116
140
|
// Data transforms
|
|
117
141
|
// ---------------------------------------------------------------------------
|
|
@@ -141,6 +165,8 @@ export type {
|
|
|
141
165
|
GraphLayout,
|
|
142
166
|
GraphSpec,
|
|
143
167
|
LayerSpec,
|
|
168
|
+
MapLayout,
|
|
169
|
+
MapSpec,
|
|
144
170
|
SankeyLayout,
|
|
145
171
|
SankeySpec,
|
|
146
172
|
TableLayout,
|
package/src/layout/axes/ticks.ts
CHANGED
|
@@ -7,13 +7,12 @@
|
|
|
7
7
|
|
|
8
8
|
import type { AxisLabelDensity, AxisTick, DataRow } from '@opendata-ai/openchart-core';
|
|
9
9
|
import {
|
|
10
|
-
abbreviateNumber,
|
|
11
|
-
buildD3Formatter,
|
|
12
10
|
buildTemporalFormatter,
|
|
13
11
|
formatDate,
|
|
14
|
-
|
|
12
|
+
resolveNumberFormatter,
|
|
15
13
|
} from '@opendata-ai/openchart-core';
|
|
16
14
|
import type { ScaleBand } from 'd3-scale';
|
|
15
|
+
import { resolveFieldFormatter } from '../../format/field-format';
|
|
17
16
|
import type { D3CategoricalScale, D3ContinuousScale, ResolvedScale } from '../scales';
|
|
18
17
|
|
|
19
18
|
/**
|
|
@@ -122,8 +121,18 @@ const NUMERIC_SCALE_TYPES = new Set([
|
|
|
122
121
|
/** Set of temporal scale types. */
|
|
123
122
|
const TEMPORAL_SCALE_TYPES = new Set(['time', 'utc']);
|
|
124
123
|
|
|
124
|
+
interface TickContext {
|
|
125
|
+
step?: number;
|
|
126
|
+
maxAbsTick?: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
125
129
|
/** Format a tick value based on the scale type. */
|
|
126
|
-
function formatTickLabel(
|
|
130
|
+
function formatTickLabel(
|
|
131
|
+
value: unknown,
|
|
132
|
+
resolvedScale: ResolvedScale,
|
|
133
|
+
compact = false,
|
|
134
|
+
tickContext?: TickContext,
|
|
135
|
+
): string {
|
|
127
136
|
const axisConfig = resolvedScale.channel.axis || undefined;
|
|
128
137
|
const formatStr = axisConfig?.format;
|
|
129
138
|
const suffix = axisConfig?.labelSuffix ?? '';
|
|
@@ -139,17 +148,54 @@ function formatTickLabel(value: unknown, resolvedScale: ResolvedScale, compact =
|
|
|
139
148
|
if (NUMERIC_SCALE_TYPES.has(resolvedScale.type)) {
|
|
140
149
|
const num = value as number;
|
|
141
150
|
if (formatStr) {
|
|
142
|
-
const
|
|
151
|
+
const ctx = {
|
|
152
|
+
...resolvedScale.formatContext,
|
|
153
|
+
step: tickContext?.step,
|
|
154
|
+
stepReference: tickContext?.maxAbsTick,
|
|
155
|
+
};
|
|
156
|
+
const fmt = resolveNumberFormatter(formatStr, ctx);
|
|
143
157
|
if (fmt) return fmt(num) + suffix;
|
|
144
158
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
159
|
+
const fmt = resolveFieldFormatter({
|
|
160
|
+
channelFormat: resolvedScale.channel.format,
|
|
161
|
+
context: {
|
|
162
|
+
...resolvedScale.formatContext,
|
|
163
|
+
step: tickContext?.step,
|
|
164
|
+
stepReference: tickContext?.maxAbsTick,
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
return fmt(num) + suffix;
|
|
148
168
|
}
|
|
149
169
|
|
|
150
170
|
return String(value) + suffix;
|
|
151
171
|
}
|
|
152
172
|
|
|
173
|
+
function computeTickContext(ticks: unknown[]): TickContext | undefined {
|
|
174
|
+
if (ticks.length < 2) return undefined;
|
|
175
|
+
const nums = ticks.filter((v) => typeof v === 'number' && Number.isFinite(v)) as number[];
|
|
176
|
+
if (nums.length < 2) return undefined;
|
|
177
|
+
|
|
178
|
+
const firstGap = Math.abs(nums[1] - nums[0]);
|
|
179
|
+
if (firstGap === 0) return undefined;
|
|
180
|
+
|
|
181
|
+
let uniform = true;
|
|
182
|
+
for (let i = 2; i < nums.length; i++) {
|
|
183
|
+
const gap = Math.abs(nums[i] - nums[i - 1]);
|
|
184
|
+
const relDiff = Math.abs(gap - firstGap) / Math.max(firstGap, 1e-15);
|
|
185
|
+
if (relDiff > 1e-9) {
|
|
186
|
+
uniform = false;
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!uniform) return undefined;
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
step: firstGap,
|
|
195
|
+
maxAbsTick: Math.max(Math.abs(nums[0]), Math.abs(nums[nums.length - 1])),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
153
199
|
/**
|
|
154
200
|
* Generate ticks for a continuous scale (linear, time, log, pow, sqrt, symlog).
|
|
155
201
|
*
|
|
@@ -225,10 +271,12 @@ export function buildContinuousTicks(
|
|
|
225
271
|
}
|
|
226
272
|
}
|
|
227
273
|
|
|
274
|
+
const tickCtx = computeTickContext(ticks);
|
|
275
|
+
|
|
228
276
|
return ticks.map((value: unknown) => ({
|
|
229
277
|
value,
|
|
230
278
|
position: scale(value as number & Date) as number,
|
|
231
|
-
label: formatTickLabel(value, resolvedScale, compact),
|
|
279
|
+
label: formatTickLabel(value, resolvedScale, compact, tickCtx),
|
|
232
280
|
}));
|
|
233
281
|
}
|
|
234
282
|
|
|
@@ -333,6 +381,7 @@ export function categoricalTicks(
|
|
|
333
381
|
*/
|
|
334
382
|
export function resolveExplicitTicks(values: unknown[], resolvedScale: ResolvedScale): AxisTick[] {
|
|
335
383
|
const scale = resolvedScale.scale;
|
|
384
|
+
const tickCtx = computeTickContext(values);
|
|
336
385
|
return values.map((value) => {
|
|
337
386
|
let position: number;
|
|
338
387
|
if (TEMPORAL_SCALE_TYPES.has(resolvedScale.type)) {
|
|
@@ -354,7 +403,7 @@ export function resolveExplicitTicks(values: unknown[], resolvedScale: ResolvedS
|
|
|
354
403
|
return {
|
|
355
404
|
value,
|
|
356
405
|
position,
|
|
357
|
-
label: formatTickLabel(value, resolvedScale),
|
|
406
|
+
label: formatTickLabel(value, resolvedScale, false, tickCtx),
|
|
358
407
|
};
|
|
359
408
|
});
|
|
360
409
|
}
|
package/src/layout/axes.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
computeXAxisExtentFromLabels,
|
|
25
25
|
estimateTextWidth,
|
|
26
26
|
getAxisTitleOffset,
|
|
27
|
+
truncateRotatedLabel,
|
|
27
28
|
X_AXIS_TITLE_BAND_ROTATED,
|
|
28
29
|
} from '@opendata-ai/openchart-core';
|
|
29
30
|
import type { ScaleBand } from 'd3-scale';
|
|
@@ -513,6 +514,30 @@ export function computeAxes(
|
|
|
513
514
|
ticks = thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText);
|
|
514
515
|
}
|
|
515
516
|
|
|
517
|
+
// Truncate rotated labels that would overflow the capped reservation.
|
|
518
|
+
// computeXAxisExtentFromLabels clamps the reserved band to
|
|
519
|
+
// X_AXIS_ROTATED_EXTENT_CAP, but nothing shortened the drawn string, so a
|
|
520
|
+
// long label rendered straight past the axis and through the source line.
|
|
521
|
+
// Clip to the same budget the reservation is computed from. `fullLabel`
|
|
522
|
+
// carries the original text so tooltips and the a11y table stay complete.
|
|
523
|
+
if (tickAngle && Math.abs(tickAngle) > 10) {
|
|
524
|
+
// MeasureTextFn returns { width }, not a number — adapt it the same way
|
|
525
|
+
// measureLabel does, or every comparison against the width budget is NaN
|
|
526
|
+
// and the search collapses every label to a bare ellipsis.
|
|
527
|
+
const measureWidth = (text: string, size: number, weight?: number) =>
|
|
528
|
+
measureLabel(text, size, weight ?? fontWeight, measureText);
|
|
529
|
+
ticks = ticks.map((t) => {
|
|
530
|
+
const truncated = truncateRotatedLabel(
|
|
531
|
+
t.label,
|
|
532
|
+
tickAngle,
|
|
533
|
+
fontSize,
|
|
534
|
+
fontWeight,
|
|
535
|
+
measureWidth,
|
|
536
|
+
);
|
|
537
|
+
return truncated === t.label ? t : { ...t, label: truncated, fullLabel: t.label };
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
516
541
|
const axisTitle = axisConfig?.title;
|
|
517
542
|
const xLabelColor = axisConfig?.labelColor;
|
|
518
543
|
// X-axis defaults to gutter (no inline mode is sensible for the x axis
|
|
@@ -637,8 +662,11 @@ export function computeAxes(
|
|
|
637
662
|
|
|
638
663
|
result.y = {
|
|
639
664
|
ticks,
|
|
640
|
-
// Y-axis gridlines are shown by default (standard editorial practice)
|
|
641
|
-
|
|
665
|
+
// Y-axis gridlines are shown by default (standard editorial practice).
|
|
666
|
+
// Not for heatmaps: the cells tile the plot, so a gridline can only draw
|
|
667
|
+
// *through* them. The cell grid already is the grid. Explicit `grid: true`
|
|
668
|
+
// still wins.
|
|
669
|
+
gridlines: (axisConfig?.grid ?? dataContext?.markType !== 'rect') ? gridlines : [],
|
|
642
670
|
label: axisTitle,
|
|
643
671
|
labelStyle: yLabelColor ? { ...axisLabelStyle, fill: yLabelColor } : axisLabelStyle,
|
|
644
672
|
tickLabelStyle: yLabelColor ? { ...tickLabelStyle, fill: yLabelColor } : tickLabelStyle,
|