@opendata-ai/openchart-engine 6.27.2 → 6.28.2
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 +38 -6
- package/dist/index.js +1009 -520
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +31 -4
- package/src/__tests__/legend.test.ts +2 -2
- package/src/barlist/__tests__/compile-barlist.test.ts +200 -0
- package/src/barlist/compile-barlist.ts +380 -0
- package/src/barlist/types.ts +28 -0
- package/src/charts/bar/__tests__/compute.test.ts +120 -0
- package/src/charts/bar/compute.ts +77 -45
- package/src/charts/bar/index.ts +1 -0
- package/src/charts/bar/labels.ts +3 -2
- package/src/charts/column/compute.ts +60 -27
- package/src/charts/column/index.ts +1 -0
- package/src/charts/column/labels.ts +2 -1
- package/src/charts/line/__tests__/compute.test.ts +2 -2
- package/src/charts/line/area.ts +25 -4
- package/src/charts/line/compute.ts +15 -5
- package/src/compile.ts +26 -1
- package/src/compiler/normalize.ts +25 -1
- package/src/compiler/types.ts +5 -3
- package/src/compiler/validate.ts +120 -5
- package/src/index.ts +5 -0
- package/src/layout/axes/ticks.ts +6 -4
- package/src/layout/axes.ts +2 -2
- package/src/layout/dimensions.ts +10 -4
- package/src/layout/scales.ts +10 -0
- package/src/legend/wrap.ts +1 -1
- package/src/tilemap/__tests__/compile-tilemap.test.ts +5 -2
- package/src/tilemap/compile-tilemap.ts +41 -29
- package/src/tooltips/compute.ts +4 -2
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type {
|
|
12
12
|
Annotation,
|
|
13
|
+
BarListSpec,
|
|
13
14
|
ChartSpec,
|
|
14
15
|
Chrome,
|
|
15
16
|
ChromeText,
|
|
@@ -25,6 +26,7 @@ import type {
|
|
|
25
26
|
VizSpec,
|
|
26
27
|
} from '@opendata-ai/openchart-core';
|
|
27
28
|
import {
|
|
29
|
+
isBarListSpec,
|
|
28
30
|
isChartSpec,
|
|
29
31
|
isGraphSpec,
|
|
30
32
|
isLayerSpec,
|
|
@@ -34,6 +36,7 @@ import {
|
|
|
34
36
|
resolveMarkDef,
|
|
35
37
|
resolveMarkType,
|
|
36
38
|
} from '@opendata-ai/openchart-core';
|
|
39
|
+
import type { NormalizedBarListSpec } from '../barlist/types';
|
|
37
40
|
import type { NormalizedSankeySpec } from '../sankey/types';
|
|
38
41
|
import { STATE_CODE_SET } from '../tilemap/layout';
|
|
39
42
|
import type { NormalizedTileMapSpec } from '../tilemap/types';
|
|
@@ -206,6 +209,7 @@ function normalizeLabels(labels?: LabelSpec): NormalizedChartSpec['labels'] {
|
|
|
206
209
|
format: labels.format ?? '',
|
|
207
210
|
prefix: labels.prefix ?? '',
|
|
208
211
|
offsets: labels.offsets,
|
|
212
|
+
color: labels.color,
|
|
209
213
|
};
|
|
210
214
|
}
|
|
211
215
|
|
|
@@ -382,6 +386,23 @@ function normalizeTileMapSpec(spec: TileMapSpec, warnings: string[]): Normalized
|
|
|
382
386
|
};
|
|
383
387
|
}
|
|
384
388
|
|
|
389
|
+
function normalizeBarListSpec(spec: BarListSpec, _warnings: string[]): NormalizedBarListSpec {
|
|
390
|
+
return {
|
|
391
|
+
type: 'barlist',
|
|
392
|
+
data: spec.data,
|
|
393
|
+
encoding: spec.encoding,
|
|
394
|
+
barHeight: spec.barHeight ?? 6,
|
|
395
|
+
cornerRadius: spec.cornerRadius ?? 'pill',
|
|
396
|
+
maxItems: spec.maxItems ?? 20,
|
|
397
|
+
chrome: normalizeChrome(spec.chrome),
|
|
398
|
+
theme: spec.theme ?? {},
|
|
399
|
+
darkMode: spec.darkMode ?? 'off',
|
|
400
|
+
watermark: spec.watermark ?? true,
|
|
401
|
+
animation: spec.animation ?? true,
|
|
402
|
+
valueFormat: spec.valueFormat,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
385
406
|
// ---------------------------------------------------------------------------
|
|
386
407
|
// Public API
|
|
387
408
|
// ---------------------------------------------------------------------------
|
|
@@ -419,9 +440,12 @@ export function normalizeSpec(spec: VizSpec, warnings: string[] = []): Normalize
|
|
|
419
440
|
if (isTileMapSpec(spec)) {
|
|
420
441
|
return normalizeTileMapSpec(spec, warnings);
|
|
421
442
|
}
|
|
443
|
+
if (isBarListSpec(spec)) {
|
|
444
|
+
return normalizeBarListSpec(spec, warnings);
|
|
445
|
+
}
|
|
422
446
|
// Should never happen after validation
|
|
423
447
|
throw new Error(
|
|
424
|
-
`Unknown spec shape. Expected mark (chart), layer, type: 'table', type: 'graph', type: 'sankey', or type: '
|
|
448
|
+
`Unknown spec shape. Expected mark (chart), layer, type: 'table', type: 'graph', type: 'sankey', type: 'tilemap', or type: 'barlist'.`,
|
|
425
449
|
);
|
|
426
450
|
}
|
|
427
451
|
|
package/src/compiler/types.ts
CHANGED
|
@@ -29,6 +29,7 @@ import type {
|
|
|
29
29
|
ScaleConfig,
|
|
30
30
|
ThemeConfig,
|
|
31
31
|
} from '@opendata-ai/openchart-core';
|
|
32
|
+
import type { NormalizedBarListSpec } from '../barlist/types';
|
|
32
33
|
import type { NormalizedSankeySpec } from '../sankey/types';
|
|
33
34
|
import type { NormalizedTileMapSpec } from '../tilemap/types';
|
|
34
35
|
|
|
@@ -101,9 +102,9 @@ export interface NormalizedChartSpec {
|
|
|
101
102
|
encoding: Encoding;
|
|
102
103
|
chrome: NormalizedChrome;
|
|
103
104
|
annotations: Annotation[];
|
|
104
|
-
/** Normalized label configuration with defaults applied. density, format, and prefix are always set; offsets
|
|
105
|
+
/** Normalized label configuration with defaults applied. density, format, and prefix are always set; offsets and color stay optional. */
|
|
105
106
|
labels: Required<Pick<LabelConfig, 'density' | 'format' | 'prefix'>> &
|
|
106
|
-
Pick<LabelConfig, 'offsets'>;
|
|
107
|
+
Pick<LabelConfig, 'offsets' | 'color'>;
|
|
107
108
|
/** Legend configuration (position override). */
|
|
108
109
|
legend?: LegendConfig;
|
|
109
110
|
responsive: boolean;
|
|
@@ -164,7 +165,8 @@ export type NormalizedSpec =
|
|
|
164
165
|
| NormalizedTableSpec
|
|
165
166
|
| NormalizedGraphSpec
|
|
166
167
|
| NormalizedSankeySpec
|
|
167
|
-
| NormalizedTileMapSpec
|
|
168
|
+
| NormalizedTileMapSpec
|
|
169
|
+
| NormalizedBarListSpec;
|
|
168
170
|
|
|
169
171
|
// ---------------------------------------------------------------------------
|
|
170
172
|
// Validation types
|
package/src/compiler/validate.ts
CHANGED
|
@@ -759,6 +759,117 @@ function validateTileMapSpec(spec: Record<string, unknown>, errors: ValidationEr
|
|
|
759
759
|
}
|
|
760
760
|
}
|
|
761
761
|
|
|
762
|
+
// ---------------------------------------------------------------------------
|
|
763
|
+
// BarList validation
|
|
764
|
+
// ---------------------------------------------------------------------------
|
|
765
|
+
|
|
766
|
+
function validateBarListSpec(spec: Record<string, unknown>, errors: ValidationError[]): void {
|
|
767
|
+
if (!Array.isArray(spec.data)) {
|
|
768
|
+
errors.push({
|
|
769
|
+
message: 'Spec error: barlist spec requires a "data" array',
|
|
770
|
+
path: 'data',
|
|
771
|
+
code: 'INVALID_TYPE',
|
|
772
|
+
suggestion:
|
|
773
|
+
'Provide data as an array of objects, e.g. data: [{ label: "Category A", value: 42 }]',
|
|
774
|
+
});
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (spec.data.length === 0) {
|
|
779
|
+
errors.push({
|
|
780
|
+
message: 'Spec error: "data" array must be non-empty',
|
|
781
|
+
path: 'data',
|
|
782
|
+
code: 'EMPTY_DATA',
|
|
783
|
+
suggestion: 'Add at least one data row',
|
|
784
|
+
});
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const firstRow = spec.data[0] as unknown;
|
|
789
|
+
if (typeof firstRow !== 'object' || firstRow === null || Array.isArray(firstRow)) {
|
|
790
|
+
errors.push({
|
|
791
|
+
message: 'Spec error: each item in "data" must be a plain object',
|
|
792
|
+
path: 'data[0]',
|
|
793
|
+
code: 'INVALID_TYPE',
|
|
794
|
+
suggestion: 'Each data item should be an object, e.g. { label: "Category A", value: 42 }',
|
|
795
|
+
});
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (!spec.encoding || typeof spec.encoding !== 'object') {
|
|
800
|
+
errors.push({
|
|
801
|
+
message:
|
|
802
|
+
'Spec error: barlist spec requires an "encoding" object with label and value channels',
|
|
803
|
+
path: 'encoding',
|
|
804
|
+
code: 'MISSING_FIELD',
|
|
805
|
+
suggestion:
|
|
806
|
+
'Add an encoding object, e.g. encoding: { label: { field: "name", type: "nominal" }, value: { field: "count", type: "quantitative" } }',
|
|
807
|
+
});
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const encoding = spec.encoding as Record<string, unknown>;
|
|
812
|
+
const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
|
|
813
|
+
const availableColumns = [...dataColumns].join(', ');
|
|
814
|
+
|
|
815
|
+
for (const channel of ['label', 'value'] as const) {
|
|
816
|
+
const ch = encoding[channel] as Record<string, unknown> | undefined;
|
|
817
|
+
if (!ch || typeof ch !== 'object') {
|
|
818
|
+
errors.push({
|
|
819
|
+
message: `Spec error: barlist encoding requires "${channel}" channel`,
|
|
820
|
+
path: `encoding.${channel}`,
|
|
821
|
+
code: 'MISSING_FIELD',
|
|
822
|
+
suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${[...dataColumns][0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
|
|
823
|
+
});
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (!ch.field || typeof ch.field !== 'string') {
|
|
828
|
+
errors.push({
|
|
829
|
+
message: `Spec error: encoding.${channel} must have a "field" string`,
|
|
830
|
+
path: `encoding.${channel}.field`,
|
|
831
|
+
code: 'MISSING_FIELD',
|
|
832
|
+
suggestion: `Add a field name from your data columns: ${availableColumns}`,
|
|
833
|
+
});
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (!dataColumns.has(ch.field as string)) {
|
|
838
|
+
errors.push({
|
|
839
|
+
message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist in data. Available columns: ${availableColumns}`,
|
|
840
|
+
path: `encoding.${channel}.field`,
|
|
841
|
+
code: 'DATA_FIELD_MISSING',
|
|
842
|
+
suggestion: `Use one of the available data columns: ${availableColumns}`,
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Validate optional encoding channels
|
|
848
|
+
for (const channel of ['subtitle', 'color', 'tooltip'] as const) {
|
|
849
|
+
const ch = encoding[channel] as Record<string, unknown> | undefined;
|
|
850
|
+
if (!ch) continue;
|
|
851
|
+
const field = ch.field;
|
|
852
|
+
if (field && typeof field === 'string' && !dataColumns.has(field)) {
|
|
853
|
+
errors.push({
|
|
854
|
+
message: `Spec error: encoding.${channel}.field "${field}" does not exist in data. Available columns: ${availableColumns}`,
|
|
855
|
+
path: `encoding.${channel}.field`,
|
|
856
|
+
code: 'DATA_FIELD_MISSING',
|
|
857
|
+
suggestion: `Use one of the available data columns: ${availableColumns}`,
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) {
|
|
863
|
+
errors.push({
|
|
864
|
+
message: 'Spec error: darkMode must be "auto", "force", or "off"',
|
|
865
|
+
path: 'darkMode',
|
|
866
|
+
code: 'INVALID_VALUE',
|
|
867
|
+
suggestion:
|
|
868
|
+
'Use one of: "auto" (system preference), "force" (always dark), or "off" (always light)',
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
762
873
|
// ---------------------------------------------------------------------------
|
|
763
874
|
// Layer validation
|
|
764
875
|
// ---------------------------------------------------------------------------
|
|
@@ -897,19 +1008,21 @@ export function validateSpec(spec: unknown): ValidationResult {
|
|
|
897
1008
|
const isGraph = obj.type === 'graph';
|
|
898
1009
|
const isSankey = obj.type === 'sankey';
|
|
899
1010
|
const isTileMap = obj.type === 'tilemap';
|
|
900
|
-
const
|
|
901
|
-
const
|
|
1011
|
+
const isBarList = obj.type === 'barlist';
|
|
1012
|
+
const isLayer = hasLayer && !isTable && !isGraph && !isSankey && !isTileMap && !isBarList;
|
|
1013
|
+
const isChart =
|
|
1014
|
+
hasMark && !hasLayer && !isTable && !isGraph && !isSankey && !isTileMap && !isBarList;
|
|
902
1015
|
|
|
903
|
-
if (!isChart && !isTable && !isGraph && !isSankey && !isTileMap && !isLayer) {
|
|
1016
|
+
if (!isChart && !isTable && !isGraph && !isSankey && !isTileMap && !isBarList && !isLayer) {
|
|
904
1017
|
return {
|
|
905
1018
|
valid: false,
|
|
906
1019
|
errors: [
|
|
907
1020
|
{
|
|
908
1021
|
message:
|
|
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',
|
|
1022
|
+
'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/barlist',
|
|
910
1023
|
path: 'mark',
|
|
911
1024
|
code: 'MISSING_FIELD',
|
|
912
|
-
suggestion: `Add a "mark" field for charts (e.g. mark: "bar"), a "layer" array for layered charts, or a "type" field
|
|
1025
|
+
suggestion: `Add a "mark" field for charts (e.g. mark: "bar"), a "layer" array for layered charts, or a "type" field (type: "table", type: "graph", type: "sankey", type: "tilemap", or type: "barlist"). Valid mark types: ${[...MARK_TYPES].join(', ')}`,
|
|
913
1026
|
},
|
|
914
1027
|
],
|
|
915
1028
|
normalized: null,
|
|
@@ -956,6 +1069,8 @@ export function validateSpec(spec: unknown): ValidationResult {
|
|
|
956
1069
|
validateSankeySpec(obj, errors);
|
|
957
1070
|
} else if (isTileMap) {
|
|
958
1071
|
validateTileMapSpec(obj, errors);
|
|
1072
|
+
} else if (isBarList) {
|
|
1073
|
+
validateBarListSpec(obj, errors);
|
|
959
1074
|
}
|
|
960
1075
|
|
|
961
1076
|
if (errors.length > 0) {
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
14
14
|
|
|
15
15
|
export {
|
|
16
|
+
compileBarList,
|
|
16
17
|
compileChart,
|
|
17
18
|
compileGraph,
|
|
18
19
|
compileLayer,
|
|
@@ -48,6 +49,8 @@ export type { NormalizedSankeySpec } from './sankey/types';
|
|
|
48
49
|
// TileMap compilation types
|
|
49
50
|
// ---------------------------------------------------------------------------
|
|
50
51
|
|
|
52
|
+
export type { NormalizedBarListSpec } from './barlist/types';
|
|
53
|
+
|
|
51
54
|
export type { NormalizedTileMapSpec } from './tilemap/types';
|
|
52
55
|
|
|
53
56
|
// ---------------------------------------------------------------------------
|
|
@@ -102,6 +105,8 @@ export {
|
|
|
102
105
|
// ---------------------------------------------------------------------------
|
|
103
106
|
|
|
104
107
|
export type {
|
|
108
|
+
BarListLayout,
|
|
109
|
+
BarListSpec,
|
|
105
110
|
ChartLayout,
|
|
106
111
|
ChartSpec,
|
|
107
112
|
CompileOptions,
|
package/src/layout/axes/ticks.ts
CHANGED
|
@@ -130,7 +130,8 @@ const TEMPORAL_SCALE_TYPES = new Set(['time', 'utc']);
|
|
|
130
130
|
|
|
131
131
|
/** Format a tick value based on the scale type. */
|
|
132
132
|
function formatTickLabel(value: unknown, resolvedScale: ResolvedScale): string {
|
|
133
|
-
const
|
|
133
|
+
const axisConfig = resolvedScale.channel.axis || undefined;
|
|
134
|
+
const formatStr = axisConfig?.format;
|
|
134
135
|
|
|
135
136
|
if (TEMPORAL_SCALE_TYPES.has(resolvedScale.type)) {
|
|
136
137
|
const temporalFmt = buildTemporalFormatter(formatStr);
|
|
@@ -179,8 +180,8 @@ export function continuousTicks(
|
|
|
179
180
|
}));
|
|
180
181
|
}
|
|
181
182
|
|
|
182
|
-
const
|
|
183
|
-
const count =
|
|
183
|
+
const axCfg = resolvedScale.channel.axis || undefined;
|
|
184
|
+
const count = axCfg?.tickCount ?? targetCount ?? TICK_COUNTS[density];
|
|
184
185
|
return buildContinuousTicks(resolvedScale, count);
|
|
185
186
|
}
|
|
186
187
|
|
|
@@ -257,7 +258,8 @@ export function categoricalTicks(
|
|
|
257
258
|
): AxisTick[] {
|
|
258
259
|
const scale = resolvedScale.scale as D3CategoricalScale;
|
|
259
260
|
const domain: string[] = scale.domain();
|
|
260
|
-
const
|
|
261
|
+
const catAxisCfg = resolvedScale.channel.axis || undefined;
|
|
262
|
+
const explicitTickCount = catAxisCfg?.tickCount;
|
|
261
263
|
|
|
262
264
|
let selectedValues = domain;
|
|
263
265
|
|
package/src/layout/axes.ts
CHANGED
|
@@ -271,7 +271,7 @@ export function computeAxes(
|
|
|
271
271
|
const { fontSize } = tickLabelStyle;
|
|
272
272
|
const { fontWeight } = tickLabelStyle;
|
|
273
273
|
|
|
274
|
-
if (scales.x && !dataContext?.skipX) {
|
|
274
|
+
if (scales.x && !dataContext?.skipX && scales.x.channel.axis !== false) {
|
|
275
275
|
const axisConfig = scales.x.channel.axis;
|
|
276
276
|
const isContinuousX =
|
|
277
277
|
scales.x.type !== 'band' && scales.x.type !== 'point' && scales.x.type !== 'ordinal';
|
|
@@ -373,7 +373,7 @@ export function computeAxes(
|
|
|
373
373
|
};
|
|
374
374
|
}
|
|
375
375
|
|
|
376
|
-
if (scales.y && !dataContext?.skipY) {
|
|
376
|
+
if (scales.y && !dataContext?.skipY && scales.y.channel.axis !== false) {
|
|
377
377
|
const axisConfig = scales.y.channel.axis;
|
|
378
378
|
const isContinuousY =
|
|
379
379
|
scales.y.type !== 'band' && scales.y.type !== 'point' && scales.y.type !== 'ordinal';
|
package/src/layout/dimensions.ts
CHANGED
|
@@ -114,7 +114,9 @@ function getMinChartDims(display: import('@opendata-ai/openchart-core').Display)
|
|
|
114
114
|
*/
|
|
115
115
|
function getSparklinePad(spec: NormalizedChartSpec): number {
|
|
116
116
|
const strokeWidth = (spec.markDef as { strokeWidth?: number }).strokeWidth ?? 2;
|
|
117
|
-
|
|
117
|
+
const hasPoints = !!(spec.markDef as { point?: unknown }).point;
|
|
118
|
+
const pointRadius = hasPoints ? 3 : 0;
|
|
119
|
+
return Math.max(strokeWidth / 2 + 1, pointRadius + 1, 2);
|
|
118
120
|
}
|
|
119
121
|
|
|
120
122
|
// ---------------------------------------------------------------------------
|
|
@@ -222,12 +224,15 @@ export function computeDimensions(
|
|
|
222
224
|
// Estimate x-axis height below chart area: tick labels sit 14px below,
|
|
223
225
|
// axis title sits 35px below. These extend past the chart area bottom
|
|
224
226
|
// and source/footer chrome must be positioned below them.
|
|
225
|
-
const
|
|
227
|
+
const xAxisSuppressed = encoding.x?.axis === false;
|
|
228
|
+
const xAxis = (!xAxisSuppressed && encoding.x?.axis) as
|
|
229
|
+
| (Record<string, unknown> & { labelAngle?: number })
|
|
230
|
+
| undefined;
|
|
226
231
|
const hasXAxisLabel = !!xAxis?.title;
|
|
227
232
|
const xTickAngle = xAxis?.labelAngle;
|
|
228
233
|
|
|
229
234
|
let xAxisHeight: number;
|
|
230
|
-
if (isRadial) {
|
|
235
|
+
if (isRadial || xAxisSuppressed) {
|
|
231
236
|
xAxisHeight = 0;
|
|
232
237
|
} else if (xTickAngle && Math.abs(xTickAngle) > 10) {
|
|
233
238
|
// Rotated labels: estimate height from the longest label text.
|
|
@@ -339,7 +344,8 @@ export function computeDimensions(
|
|
|
339
344
|
}
|
|
340
345
|
|
|
341
346
|
// Dynamic left margin for y-axis labels
|
|
342
|
-
|
|
347
|
+
const yAxisSuppressed = encoding.y?.axis === false;
|
|
348
|
+
if (encoding.y && !isRadial && !yAxisSuppressed) {
|
|
343
349
|
if (
|
|
344
350
|
spec.markType === 'bar' ||
|
|
345
351
|
spec.markType === 'circle' ||
|
package/src/layout/scales.ts
CHANGED
|
@@ -256,6 +256,16 @@ function buildLinearScale(
|
|
|
256
256
|
|
|
257
257
|
if (!channel.scale?.domain && channel.scale?.nice !== false) {
|
|
258
258
|
scale.nice();
|
|
259
|
+
|
|
260
|
+
// nice() can round the domain min down to 0 even when zero: false.
|
|
261
|
+
// Re-nice with more ticks to tighten the domain around the data range.
|
|
262
|
+
if (channel.scale?.zero === false) {
|
|
263
|
+
const [nicedMin, nicedMax] = scale.domain();
|
|
264
|
+
if (nicedMin < domainMin || nicedMax > domainMax) {
|
|
265
|
+
scale.domain([domainMin, domainMax]);
|
|
266
|
+
scale.nice(20);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
259
269
|
}
|
|
260
270
|
applyContinuousConfig(scale, channel);
|
|
261
271
|
|
package/src/legend/wrap.ts
CHANGED
|
@@ -32,7 +32,7 @@ export const ENTRY_GAP = 16;
|
|
|
32
32
|
export const ENTRY_GAP_COMPACT = 10;
|
|
33
33
|
|
|
34
34
|
/** Default gap between legend bounds and chart area. Zero on narrow viewports. */
|
|
35
|
-
export const LEGEND_GAP =
|
|
35
|
+
export const LEGEND_GAP = 8;
|
|
36
36
|
|
|
37
37
|
/** Gap between legend and chart area, responsive to container width. */
|
|
38
38
|
export function legendGap(width: number): number {
|
|
@@ -112,7 +112,7 @@ describe('compileTileMap', () => {
|
|
|
112
112
|
}
|
|
113
113
|
});
|
|
114
114
|
|
|
115
|
-
it('data tiles
|
|
115
|
+
it('data tiles use opacity-based encoding with a single base color', () => {
|
|
116
116
|
const result = compileTileMap(basicSpec, defaultOptions);
|
|
117
117
|
|
|
118
118
|
const dataTiles = result.tiles.filter((t) => t.hasData);
|
|
@@ -122,7 +122,10 @@ describe('compileTileMap', () => {
|
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
const fills = new Set(dataTiles.map((t) => t.fill));
|
|
125
|
-
expect(fills.size).
|
|
125
|
+
expect(fills.size).toBe(1);
|
|
126
|
+
|
|
127
|
+
const opacities = new Set(dataTiles.map((t) => t.fillOpacity));
|
|
128
|
+
expect(opacities.size).toBeGreaterThan(1);
|
|
126
129
|
});
|
|
127
130
|
|
|
128
131
|
it('compiles tabular DataRow[] data with encoding', () => {
|
|
@@ -44,8 +44,8 @@ import type { NormalizedTileMapSpec } from './types';
|
|
|
44
44
|
// Constants
|
|
45
45
|
// ---------------------------------------------------------------------------
|
|
46
46
|
|
|
47
|
-
const TILE_CORNER_RADIUS =
|
|
48
|
-
const TILE_STROKE_WIDTH =
|
|
47
|
+
const TILE_CORNER_RADIUS = 6;
|
|
48
|
+
const TILE_STROKE_WIDTH = 1;
|
|
49
49
|
|
|
50
50
|
// ---------------------------------------------------------------------------
|
|
51
51
|
// Public API
|
|
@@ -143,23 +143,22 @@ export function compileTileMap(spec: unknown, options: CompileOptions): TileMapL
|
|
|
143
143
|
const min = values.length > 0 ? Math.min(...values) : 0;
|
|
144
144
|
const max = values.length > 0 ? Math.max(...values) : 100;
|
|
145
145
|
|
|
146
|
-
// 8. Build
|
|
146
|
+
// 8. Build opacity scale: single base color, opacity encodes value
|
|
147
147
|
const paletteStops = [...(SEQUENTIAL_PALETTES[tilemapSpec.palette] ?? SEQUENTIAL_PALETTES.blue)];
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
const colorScale = scaleLinear<string>().domain(domain).range(paletteStops).clamp(true);
|
|
148
|
+
const baseColor = isDarkMode ? paletteStops[0] : paletteStops[paletteStops.length - 1];
|
|
149
|
+
const opacityRange: [number, number] = isDarkMode ? [0.15, 1] : [0.2, 1];
|
|
150
|
+
const opacityScale = scaleLinear<number>().domain([min, max]).range(opacityRange).clamp(true);
|
|
152
151
|
|
|
153
152
|
// 9. Reserve space for gradient legend at bottom (unless hidden)
|
|
154
153
|
const showLegend = tilemapSpec.legend?.show !== false;
|
|
155
|
-
const legendBarHeight =
|
|
156
|
-
const legendLabelGap =
|
|
154
|
+
const legendBarHeight = 6;
|
|
155
|
+
const legendLabelGap = 6;
|
|
157
156
|
const legendTotalHeight = showLegend ? legendBarHeight + legendLabelGap + 14 : 0;
|
|
158
157
|
|
|
159
158
|
// 10. Compute tile positions in the remaining area
|
|
160
159
|
const legendGap = showLegend ? 8 : 0;
|
|
161
160
|
const tileAreaHeight = fullArea.height - legendTotalHeight - legendGap;
|
|
162
|
-
const tilePositions = computeTilePositions(fullArea.width, tileAreaHeight,
|
|
161
|
+
const tilePositions = computeTilePositions(fullArea.width, tileAreaHeight, 5);
|
|
163
162
|
|
|
164
163
|
// Center tile grid horizontally
|
|
165
164
|
const tileGridOffsetX = fullArea.x + (fullArea.width - tilePositions.gridWidth) / 2;
|
|
@@ -173,9 +172,9 @@ export function compileTileMap(spec: unknown, options: CompileOptions): TileMapL
|
|
|
173
172
|
// 11. Build TileMapTileMark[]
|
|
174
173
|
const formatter = buildD3Formatter(tilemapSpec.valueFormat) ?? formatNumber;
|
|
175
174
|
const neutralFillLight = '#e0e0e0';
|
|
176
|
-
const neutralFillDark = '#
|
|
175
|
+
const neutralFillDark = '#1e2a30';
|
|
177
176
|
const neutralStrokeLight = '#d0d0d0';
|
|
178
|
-
const neutralStrokeDark = '
|
|
177
|
+
const neutralStrokeDark = 'rgba(255,255,255,0.08)';
|
|
179
178
|
|
|
180
179
|
const neutralFill = isDarkMode ? neutralFillDark : neutralFillLight;
|
|
181
180
|
const neutralStroke = isDarkMode ? neutralStrokeDark : neutralStrokeLight;
|
|
@@ -188,12 +187,18 @@ export function compileTileMap(spec: unknown, options: CompileOptions): TileMapL
|
|
|
188
187
|
|
|
189
188
|
const hasData = stateValueMap.has(stateCode);
|
|
190
189
|
const value = hasData ? stateValueMap.get(stateCode)! : null;
|
|
191
|
-
const
|
|
190
|
+
const opacity = hasData ? opacityScale(value!) : 0;
|
|
191
|
+
const fill = hasData ? baseColor : neutralFill;
|
|
192
|
+
const stroke = hasData
|
|
193
|
+
? isDarkMode
|
|
194
|
+
? 'rgba(255,255,255,0.1)'
|
|
195
|
+
: 'rgba(0,0,0,0.1)'
|
|
196
|
+
: neutralStroke;
|
|
192
197
|
const formattedValue = hasData ? formatter(value!) : '–';
|
|
193
198
|
|
|
194
199
|
const labelStyle: TextStyle = {
|
|
195
200
|
fontFamily: theme.fonts.family,
|
|
196
|
-
fontSize: tilePositions.tileSize > 24 ?
|
|
201
|
+
fontSize: tilePositions.tileSize > 24 ? 10 : 7,
|
|
197
202
|
fontWeight: 700,
|
|
198
203
|
fill: '#ffffff',
|
|
199
204
|
lineHeight: 1.2,
|
|
@@ -201,20 +206,24 @@ export function compileTileMap(spec: unknown, options: CompileOptions): TileMapL
|
|
|
201
206
|
|
|
202
207
|
const valueLabelStyle: TextStyle = {
|
|
203
208
|
fontFamily: theme.fonts.family,
|
|
204
|
-
fontSize: tilePositions.tileSize > 24 ?
|
|
205
|
-
fontWeight:
|
|
206
|
-
fill: '
|
|
209
|
+
fontSize: tilePositions.tileSize > 24 ? 10 : 7,
|
|
210
|
+
fontWeight: 300,
|
|
211
|
+
fill: 'rgba(255,255,255,0.6)',
|
|
207
212
|
lineHeight: 1.2,
|
|
208
213
|
};
|
|
209
214
|
|
|
215
|
+
const tileCenterX = tileGridOffsetX + pos.x + tilePositions.tileSize / 2;
|
|
216
|
+
const tileTopY = tileGridOffsetY + pos.y;
|
|
217
|
+
const sz = tilePositions.tileSize;
|
|
218
|
+
|
|
210
219
|
// Only show value label on larger tiles
|
|
211
220
|
const valueLabel =
|
|
212
|
-
|
|
221
|
+
sz < 24
|
|
213
222
|
? { text: '', x: 0, y: 0, style: valueLabelStyle, visible: false }
|
|
214
223
|
: {
|
|
215
224
|
text: formattedValue,
|
|
216
|
-
x:
|
|
217
|
-
y:
|
|
225
|
+
x: tileCenterX,
|
|
226
|
+
y: tileTopY + sz * 0.78,
|
|
218
227
|
style: valueLabelStyle,
|
|
219
228
|
visible: true,
|
|
220
229
|
};
|
|
@@ -223,10 +232,11 @@ export function compileTileMap(spec: unknown, options: CompileOptions): TileMapL
|
|
|
223
232
|
type: 'tile' as const,
|
|
224
233
|
stateCode,
|
|
225
234
|
x: tileGridOffsetX + pos.x,
|
|
226
|
-
y:
|
|
227
|
-
size:
|
|
235
|
+
y: tileTopY,
|
|
236
|
+
size: sz,
|
|
228
237
|
fill,
|
|
229
|
-
|
|
238
|
+
fillOpacity: hasData ? opacity : 1,
|
|
239
|
+
stroke,
|
|
230
240
|
strokeWidth: TILE_STROKE_WIDTH,
|
|
231
241
|
cornerRadius: TILE_CORNER_RADIUS,
|
|
232
242
|
value: value ?? null,
|
|
@@ -234,8 +244,8 @@ export function compileTileMap(spec: unknown, options: CompileOptions): TileMapL
|
|
|
234
244
|
hasData,
|
|
235
245
|
label: {
|
|
236
246
|
text: stateCode,
|
|
237
|
-
x:
|
|
238
|
-
y:
|
|
247
|
+
x: tileCenterX,
|
|
248
|
+
y: tileTopY + sz * 0.28,
|
|
239
249
|
style: labelStyle,
|
|
240
250
|
visible: true,
|
|
241
251
|
},
|
|
@@ -269,10 +279,12 @@ export function compileTileMap(spec: unknown, options: CompileOptions): TileMapL
|
|
|
269
279
|
let gradientLegend: GradientLegendLayout | null = null;
|
|
270
280
|
|
|
271
281
|
if (showLegend) {
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
282
|
+
const numStops = 5;
|
|
283
|
+
const gradientColorStops: GradientColorStop[] = Array.from({ length: numStops }, (_, i) => {
|
|
284
|
+
const t = i / (numStops - 1);
|
|
285
|
+
const o = opacityRange[0] + t * (opacityRange[1] - opacityRange[0]);
|
|
286
|
+
return { offset: t, color: baseColor, opacity: o };
|
|
287
|
+
});
|
|
276
288
|
|
|
277
289
|
gradientLegend = {
|
|
278
290
|
type: 'gradient',
|
package/src/tooltips/compute.ts
CHANGED
|
@@ -59,12 +59,14 @@ function formatValue(value: unknown, fieldType?: string, format?: string): strin
|
|
|
59
59
|
|
|
60
60
|
/** Resolve the display label for an encoding channel: title > axis.title > field name. */
|
|
61
61
|
function resolveLabel(ch: EncodingChannel): string {
|
|
62
|
-
|
|
62
|
+
const ax = ch.axis || undefined;
|
|
63
|
+
return ch.title ?? ax?.title ?? ch.field;
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
/** Resolve the format string for an encoding channel: format > axis.format. */
|
|
66
67
|
function resolveFormat(ch: EncodingChannel): string | undefined {
|
|
67
|
-
|
|
68
|
+
const ax = ch.axis || undefined;
|
|
69
|
+
return ch.format ?? ax?.format;
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
/** Build tooltip fields from explicit tooltip encoding channels. */
|