@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.
@@ -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: 'tilemap'.`,
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
 
@@ -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 stays optional. */
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
@@ -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 isLayer = hasLayer && !isTable && !isGraph && !isSankey && !isTileMap;
901
- const isChart = hasMark && !hasLayer && !isTable && !isGraph && !isSankey && !isTileMap;
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 for tables/graphs/sankey/tilemap (type: "table", type: "graph", type: "sankey", or type: "tilemap"). Valid mark types: ${[...MARK_TYPES].join(', ')}`,
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,
@@ -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 formatStr = resolvedScale.channel.axis?.format;
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 explicitCount = resolvedScale.channel.axis?.tickCount;
183
- const count = explicitCount ?? targetCount ?? TICK_COUNTS[density];
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 explicitTickCount = resolvedScale.channel.axis?.tickCount;
261
+ const catAxisCfg = resolvedScale.channel.axis || undefined;
262
+ const explicitTickCount = catAxisCfg?.tickCount;
261
263
 
262
264
  let selectedValues = domain;
263
265
 
@@ -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';
@@ -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
- return Math.max(strokeWidth / 2 + 1, 2);
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 xAxis = encoding.x?.axis as (Record<string, unknown> & { labelAngle?: number }) | undefined;
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
- if (encoding.y && !isRadial) {
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' ||
@@ -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
 
@@ -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 = 4;
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 have fill colors from the sequential palette', () => {
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).toBeGreaterThan(1);
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 = 2;
48
- const TILE_STROKE_WIDTH = 0;
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 color scale (fallback to 'blue' if palette name is invalid)
146
+ // 8. Build opacity scale: single base color, opacity encodes value
147
147
  const paletteStops = [...(SEQUENTIAL_PALETTES[tilemapSpec.palette] ?? SEQUENTIAL_PALETTES.blue)];
148
- if (isDarkMode) paletteStops.reverse();
149
-
150
- const domain = paletteStops.map((_, i) => min + (i / (paletteStops.length - 1)) * (max - min));
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 = 12;
156
- const legendLabelGap = 4;
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, 4);
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 = '#2a2a3e';
175
+ const neutralFillDark = '#1e2a30';
177
176
  const neutralStrokeLight = '#d0d0d0';
178
- const neutralStrokeDark = '#3a3a50';
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 fill = hasData ? colorScale(value!) : neutralFill;
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 ? 14 : 11,
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 ? 12 : 10,
205
- fontWeight: 400,
206
- fill: '#ffffff',
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
- tilePositions.tileSize < 24
221
+ sz < 24
213
222
  ? { text: '', x: 0, y: 0, style: valueLabelStyle, visible: false }
214
223
  : {
215
224
  text: formattedValue,
216
- x: tileGridOffsetX + pos.x + tilePositions.tileSize / 2,
217
- y: tileGridOffsetY + pos.y + tilePositions.tileSize / 2 + 8,
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: tileGridOffsetY + pos.y,
227
- size: tilePositions.tileSize,
235
+ y: tileTopY,
236
+ size: sz,
228
237
  fill,
229
- stroke: neutralStroke,
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: tileGridOffsetX + pos.x + tilePositions.tileSize / 2,
238
- y: tileGridOffsetY + pos.y + tilePositions.tileSize / 2 - 4,
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 gradientColorStops: GradientColorStop[] = paletteStops.map((color, i) => ({
273
- offset: i / (paletteStops.length - 1),
274
- color,
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',
@@ -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
- return ch.title ?? ch.axis?.title ?? ch.field;
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
- return ch.format ?? ch.axis?.format;
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. */