Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions packages/flint-js/src/chartjs/templates/bubble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,17 +67,24 @@ export const cjsBubbleChartDef: ChartTemplateDef = {
const palette = getChartJsPalette(ctx, 'color');

// Radius scale spans the whole dataset so groups stay comparable.
// Treat null/empty cells as missing (NaN) rather than coercing to 0.
const num = (raw: any): number => (raw == null || raw === '' ? NaN : Number(raw));
const sizeValues = sizeField
? table.map((row) => Number(row[sizeField]))
? table.map((row) => num(row[sizeField]))
: [];
// Provisional radius range; postProcess refines rMax to canvas size.
const radiusScale = makeRadiusScale(sizeValues, 5, 24);

const toPoint = (row: any) => {
const v = sizeField ? Number(row[sizeField]) : NaN;
const x = num(row[xField]);
const y = num(row[yField]);
// Drop points with a missing/non-numeric x or y so null values don't
// collapse to phantom bubbles at the origin (0, 0).
if (!isFinite(x) || !isFinite(y)) return null;
const v = sizeField ? num(row[sizeField]) : NaN;
return {
x: Number(row[xField]),
y: Number(row[yField]),
x,
y,
r: sizeField ? radiusScale(v) : 8,
// Raw size value retained so postProcess can rescale to canvas.
_v: v,
Expand Down Expand Up @@ -106,9 +113,11 @@ export const cjsBubbleChartDef: ChartTemplateDef = {
if (colorField) {
const groups = new Map<string, any[]>();
for (const row of table) {
const point = toPoint(row);
if (!point) continue;
const key = String(row[colorField] ?? '');
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(toPoint(row));
groups.get(key)!.push(point);
}
let colorIdx = 0;
for (const [name, data] of groups) {
Expand All @@ -124,7 +133,7 @@ export const cjsBubbleChartDef: ChartTemplateDef = {
config.options.plugins.legend = { display: true };
} else {
config.data.datasets.push({
data: table.map(toPoint),
data: table.map(toPoint).filter((p) => p !== null),
backgroundColor: getSeriesBackgroundColor(palette, 0, opacity),
borderColor: getSeriesBorderColor(palette, 0),
borderWidth: 1,
Expand Down
19 changes: 12 additions & 7 deletions packages/flint-js/src/echarts/templates/area.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
*/

import { ChartTemplateDef, ChartPropertyDef } from '../../core/types';
import { extractCategories, groupBy, getCategoryOrder } from './utils';
import { extractCategories, groupBy, getCategoryOrder, toEChartsTemporal } from './utils';
import { getPaletteForScheme } from '../colormap';

const isDiscrete = (type: string | undefined) => type === 'nominal' || type === 'ordinal';
Expand Down Expand Up @@ -49,6 +49,10 @@ export const ecAreaChartDef: ChartTemplateDef = {
const xIsTemporal = xCS.type === 'temporal';
const yIsDiscrete = isDiscrete(yCS.type);
const isContinuousColor = !!colorField && (colorType === 'quantitative' || colorType === 'temporal');

// ECharts' `time` axis can't parse non-ISO date strings; pre-convert temporal
// x-values to epoch-ms so points on a time axis always render.
const xVal = (v: any) => (xIsTemporal ? toEChartsTemporal(v) : v);
const categories = xIsDiscrete
? extractCategories(table, xField, getCategoryOrder(ctx, 'x'))
: undefined;
Expand Down Expand Up @@ -133,8 +137,8 @@ export const ecAreaChartDef: ChartTemplateDef = {
return String(ax).localeCompare(String(bx));
});

const pointData = sorted.map((r: any) => [r[xField], r[yField], r[colorField]]);
const lineData = sorted.map((r: any) => [r[xField], r[yField]]);
const pointData = sorted.map((r: any) => [xVal(r[xField]), r[yField], r[colorField]]);
const lineData = sorted.map((r: any) => [xVal(r[xField]), r[yField]]);

const nums = sorted
.map((r: any) => Number(r[colorField]))
Expand Down Expand Up @@ -242,7 +246,7 @@ export const ecAreaChartDef: ChartTemplateDef = {
? buildValueAlignedYData(rows, xField, yField, sortedX)
: sortedDates
? buildTimeAlignedData(rows, xField, yField, sortedDates)
: rows.map(r => [r[xField], r[yField]]);
: rows.map(r => [xVal(r[xField]), r[yField]]);

const series: any = {
name,
Expand Down Expand Up @@ -271,7 +275,7 @@ export const ecAreaChartDef: ChartTemplateDef = {
: xIsTemporal
? (() => {
const sorted = [...table].sort((a, b) => new Date(a[xField]).getTime() - new Date(b[xField]).getTime());
return sorted.map(r => [r[xField], r[yField]]);
return sorted.map(r => [xVal(r[xField]), r[yField]]);
})()
: table.map(r => [r[xField], r[yField]]);

Expand Down Expand Up @@ -396,11 +400,12 @@ function buildTimeAlignedData(
xField: string,
yField: string,
sortedDates: string[],
): Array<[string, number]> {
): Array<[number | string, number]> {
const map = new Map<string, number>();
for (const row of rows) {
const n = Number(row[yField]);
map.set(String(row[xField]), Number.isFinite(n) ? n : 0);
}
return sortedDates.map(d => [d, map.get(d) ?? 0]);
// Emit epoch-ms for the time axis so non-ISO date strings still plot.
return sortedDates.map(d => [toEChartsTemporal(d) as number | string, map.get(d) ?? 0]);
}
20 changes: 18 additions & 2 deletions packages/flint-js/src/echarts/templates/boxplot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,22 @@ function areCategoriesNumeric(cats: string[]): boolean {
});
}

/**
* Extract finite numeric values from rows for a field, dropping null/undefined/empty
* cells instead of coercing them. `Number(null)` is `0`, which would otherwise inject
* spurious zeros (and phantom outliers at 0) for rows with missing measurements.
*/
function numericValues(rows: any[], field: string): number[] {
const out: number[] = [];
for (const r of rows) {
const raw = r[field];
if (raw == null || raw === '') continue;
const n = Number(raw);
if (isFinite(n)) out.push(n);
}
return out;
}

/** Compute the five-number summary for an array of values. */
function fiveNumberSummary(
values: number[],
Expand Down Expand Up @@ -167,7 +183,7 @@ export const ecBoxplotDef: ChartTemplateDef = {
const rows = (catGroups.get(cat) || []).filter(
(r: any) => String(r[colorField] ?? '') === colorName,
);
const values = rows.map((r: any) => Number(r[valField])).filter(v => isFinite(v));
const values = numericValues(rows, valField);
boxData.push(fiveNumberSummary(values, whiskerMethod));

if (showOutliers) {
Expand Down Expand Up @@ -205,7 +221,7 @@ export const ecBoxplotDef: ChartTemplateDef = {
for (let i = 0; i < categories.length; i++) {
const cat = categories[i];
const rows = catGroups.get(cat) || [];
const values = rows.map((r: any) => Number(r[valField])).filter((v: number) => isFinite(v));
const values = numericValues(rows, valField);
boxData.push(fiveNumberSummary(values, whiskerMethod));

if (showOutliers) {
Expand Down
40 changes: 31 additions & 9 deletions packages/flint-js/src/echarts/templates/heatmap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,24 @@ const SCHEME_COLORS: Record<string, string[]> = {

const DEFAULT_HEATMAP_SCHEME = 'blues';

/**
* Format a numeric heatmap cell value for display.
*
* Aggregated cell values are often floats with floating-point noise
* (e.g. `30.46666700000002`). Showing them at full precision makes the labels
* long and overlap into an illegible grid, so we round to a small number of
* decimals scaled to the magnitude. `maxDecimals` can force coarser rounding
* when the cell is narrow.
*/
function formatHeatLabel(val: number, maxDecimals = 2): string {
if (!Number.isFinite(val)) return '';
if (Number.isInteger(val)) return String(val);
const abs = Math.abs(val);
let decimals = abs >= 100 ? 0 : abs >= 10 ? 1 : 2;
decimals = Math.min(decimals, maxDecimals);
return val.toFixed(decimals);
}

function isDivergingHeatmapScheme(scheme: string | undefined): boolean {
return scheme === 'blueorange' || scheme === 'redblue';
}
Expand Down Expand Up @@ -228,15 +246,19 @@ export const ecHeatmapDef: ChartTemplateDef = {
} else {
const fontSize = Math.max(8, Math.min(12, Math.round(minDim * 0.2)));
heatSeries.label.fontSize = fontSize;
// If cell is narrow, truncate displayed value
if (cellW < 50) {
const maxChars = Math.max(2, Math.floor(cellW / (fontSize * 0.6)));
heatSeries.label.formatter = (params: any) => {
const val = params.data[2];
const s = String(val);
return s.length > maxChars ? s.slice(0, maxChars) : s;
};
}
// Always round labels (values are often noisy floats). Pick a decimal
// count that fits the cell width; fall back to integer, then hide if
// even that overflows — this prevents overlapping, illegible labels.
const approxCharW = fontSize * 0.62;
const maxChars = Math.max(2, Math.floor(cellW / approxCharW));
heatSeries.label.formatter = (params: any) => {
const val = Number(params.data?.[2]);
if (!Number.isFinite(val)) return '';
let s = formatHeatLabel(val);
if (s.length > maxChars) s = formatHeatLabel(val, 0);
if (s.length > maxChars) s = String(Math.round(val));
return s.length > maxChars ? '' : s;
};
}
}

Expand Down
14 changes: 9 additions & 5 deletions packages/flint-js/src/echarts/templates/line.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
*/

import { ChartTemplateDef, ChartPropertyDef } from '../../core/types';
import { extractCategories, groupBy, getCategoryOrder } from './utils';
import { extractCategories, groupBy, getCategoryOrder, toEChartsTemporal } from './utils';
import { toTypeString } from '../../core/field-semantics';
import { getPaletteForScheme } from '../colormap';
import { makeCartesianPivot } from '../../core/pivot';
Expand Down Expand Up @@ -64,6 +64,10 @@ export const ecLineChartDef: ChartTemplateDef = {
const yIsDiscrete = isDiscrete(yCS.type);
const isContinuousColor = !!colorField && (colorType === 'quantitative' || colorType === 'temporal');

// ECharts' `time` axis can't parse non-ISO date strings; pre-convert temporal
// x-values to epoch-ms so points on a time axis always render.
const xVal = (v: any) => (xIsTemporal ? toEChartsTemporal(v) : v);

// Build x-axis categories for discrete/temporal axes
const categories = xIsDiscrete ? extractCategories(table, xField, getCategoryOrder(ctx, 'x')) : undefined;
const yCategories = yIsDiscrete ? extractCategories(table, yField, getCategoryOrder(ctx, 'y')) : undefined;
Expand Down Expand Up @@ -154,8 +158,8 @@ export const ecLineChartDef: ChartTemplateDef = {
return String(ax).localeCompare(String(bx));
});

const pointData = sorted.map((r: any) => [r[xField], r[yField], r[colorField]]);
const lineData = sorted.map((r: any) => [r[xField], r[yField]]);
const pointData = sorted.map((r: any) => [xVal(r[xField]), r[yField], r[colorField]]);
const lineData = sorted.map((r: any) => [xVal(r[xField]), r[yField]]);

// VisualMap domain
const nums = sorted
Expand Down Expand Up @@ -232,7 +236,7 @@ export const ecLineChartDef: ChartTemplateDef = {
? buildCategoryAlignedXYData(rows, xField, yField, yCategories)
: xIsDiscrete
? buildCategoryAlignedData(rows, xField, yField, categories!)
: rows.map(r => [r[xField], r[yField]]);
: rows.map(r => [xVal(r[xField]), r[yField]]);

const series: any = {
name,
Expand All @@ -258,7 +262,7 @@ export const ecLineChartDef: ChartTemplateDef = {
const row = table.find(r => String(r[xField]) === cat);
return row ? row[yField] : null;
})
: table.map(r => [r[xField], r[yField]]);
: table.map(r => [xVal(r[xField]), r[yField]]);

const series: any = {
type: 'line',
Expand Down
14 changes: 9 additions & 5 deletions packages/flint-js/src/echarts/templates/streamgraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
*/

import { ChartTemplateDef } from '../../core/types';
import { groupBy } from './utils';
import { groupBy, toEChartsTemporal } from './utils';

export const ecStreamgraphDef: ChartTemplateDef = {
chart: 'Streamgraph',
Expand Down Expand Up @@ -57,7 +57,7 @@ export const ecStreamgraphDef: ChartTemplateDef = {
yAxis: { type: 'value', show: false, axisTick: { show: true } },
series: [{
type: 'line',
data: table.map(r => [r[xField], r[yField]]),
data: table.map(r => [xCS.type === 'temporal' ? toEChartsTemporal(r[xField]) : r[xField], r[yField]]),
areaStyle: { opacity: 0.85 },
lineStyle: { width: 0.5 },
symbol: 'none',
Expand Down Expand Up @@ -102,8 +102,8 @@ export const ecStreamgraphDef: ChartTemplateDef = {
const key = `${xv}|||${sn}`;
const numVal = valMap.get(key);
const value = numVal != null && Number.isFinite(numVal) ? numVal : 0;
// Use index for category so ThemeRiver renders; use string for temporal (date string)
riverData.push([xIsTemporal ? xv : i, value, sn]);
// Use index for category so ThemeRiver renders; use epoch-ms for temporal (handles non-ISO dates)
riverData.push([xIsTemporal ? (toEChartsTemporal(xv) as string | number) : i, value, sn]);
}
}

Expand All @@ -115,7 +115,11 @@ export const ecStreamgraphDef: ChartTemplateDef = {
formatter: (params: any) => {
if (!params || params.length === 0) return '';
const xVal = params[0].value[0];
const displayX = xIsTemporal ? xVal : (xVals[xVal] ?? xVal);
const displayX = xIsTemporal
? (typeof xVal === 'number'
? new Date(xVal).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
: xVal)
: (xVals[xVal] ?? xVal);
let html = `<b>${displayX}</b><br/>`;
// Sort by value descending
const sortedParams = [...params].sort((a, b) => (b.value[1] || 0) - (a.value[1] || 0));
Expand Down
20 changes: 20 additions & 0 deletions packages/flint-js/src/echarts/templates/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,26 @@ export function getCategoryOrder(ctx: InstantiateContext, channel: string): stri
?? ctx.channelSemantics?.[channel]?.ordinalSortOrder;
}

/**
* Convert a raw value into something an ECharts `time` axis can plot.
*
* ECharts' built-in time parser only understands ISO-8601-like strings, so
* common human-readable dates (e.g. `"Jan 1 2000"` from a CSV) fail to parse
* and the corresponding points are silently dropped, leaving an empty chart.
* JavaScript's `Date` constructor accepts many more formats, so we pre-parse
* temporal values to epoch-milliseconds. Numbers, `Date` instances, and values
* that cannot be parsed are returned unchanged.
*
* Only call this for values destined for a `time` axis.
*/
export function toEChartsTemporal(v: unknown): unknown {
if (v == null) return v;
if (typeof v === 'number') return v;
if (v instanceof Date) return v.getTime();
const t = new Date(String(v)).getTime();
return isNaN(t) ? v : t;
}

// Re-export circumference-pressure functions from core (shared with VL backend)
export {
computeCircumferencePressure,
Expand Down
Loading