@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.
@@ -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 isLayer = hasLayer && !isTable && !isGraph && !isSankey;
784
- const isChart = hasMark && !hasLayer && !isTable && !isGraph && !isSankey;
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: "sankey"). Valid mark types: ${[...MARK_TYPES].join(', ')}`,
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 { compileChart, compileGraph, compileLayer, compileSankey, compileTable } from './compile';
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';
@@ -5,7 +5,12 @@
5
5
  * not from the chart area. Density thinning lives in ./thinning.ts.
6
6
  */
7
7
 
8
- import type { AxisLabelDensity, AxisTick, MeasureTextFn } from '@opendata-ai/openchart-core';
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
- return {
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;
@@ -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
- allTicks = categoricalTicks(scales.y, yDensity, 'vertical');
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
  }
@@ -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 chromeMode = strategy?.chromeMode ?? 'full';
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
- const w = estimateTextWidth(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
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 < MIN_CHART_WIDTH || chartArea.height < MIN_CHART_HEIGHT) &&
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
- (legendLayout.entries.length > 0 && legendLayout.position === 'top'
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;
@@ -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,