Skip to content
Merged
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
180 changes: 180 additions & 0 deletions packages/flint-js/src/chartjs/templates/bubble.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

/**
* Chart.js Bubble Chart template.
*
* Contrast with VL / Scatter:
* VL: encoding.x + encoding.y + encoding.size (quantitative) → point area
* CJS: native type 'bubble' with data = [{x, y, r}, ...] where `r` is a
* per-point pixel radius derived (area-proportionally) from the size
* channel. Optional color channel groups points into datasets.
*/

import { ChartTemplateDef } from '../../core/types';
import {
getChartJsPalette,
getSeriesBorderColor,
getSeriesBackgroundColor,
} from './utils';

/**
* Build an area-proportional value → pixel-radius mapper for the size channel.
* Returns a constant-radius mapper when there is no usable size field.
*/
function makeRadiusScale(
values: number[],
rMin: number,
rMax: number,
): (v: number) => number {
const finite = values.filter((v) => typeof v === 'number' && isFinite(v));
if (finite.length === 0) {
const mid = Math.round((rMin + rMax) / 2);
return () => mid;
}
const min = Math.min(...finite);
const max = Math.max(...finite);
if (min === max) {
const mid = Math.round((rMin + rMax) / 2);
return () => mid;
}
// Area-proportional: interpolate area (r^2) linearly, then take sqrt.
const aMin = rMin * rMin;
const aMax = rMax * rMax;
return (v: number) => {
if (typeof v !== 'number' || !isFinite(v)) return rMin;
const t = (v - min) / (max - min);
const area = aMin + t * (aMax - aMin);
return Math.max(rMin, Math.sqrt(area));
};
}

export const cjsBubbleChartDef: ChartTemplateDef = {
chart: 'Bubble Chart',
template: { mark: 'circle', encoding: {} },
channels: ['x', 'y', 'size', 'color', 'opacity', 'column', 'row'],
markCognitiveChannel: 'position',
instantiate: (spec, ctx) => {
const { channelSemantics, table, chartProperties } = ctx;
const xField = channelSemantics.x?.field;
const yField = channelSemantics.y?.field;
const sizeField = channelSemantics.size?.field;
const colorField = channelSemantics.color?.field;

if (!xField || !yField) return;

const opacity = chartProperties?.opacity ?? 0.6;
const palette = getChartJsPalette(ctx, 'color');

// Radius scale spans the whole dataset so groups stay comparable.
const sizeValues = sizeField
? table.map((row) => Number(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;
return {
x: Number(row[xField]),
y: Number(row[yField]),
r: sizeField ? radiusScale(v) : 8,
// Raw size value retained so postProcess can rescale to canvas.
_v: v,
};
};

const config: any = {
type: 'bubble',
data: { datasets: [] },
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
// Bubble charts scale to the data extent (not a zero
// baseline) and pad both ends with `grace` so large bubbles
// sitting at the min/max aren't clipped by the plot edge.
x: { type: 'linear', grace: '10%', title: { display: true, text: xField } },
y: { type: 'linear', grace: '10%', title: { display: true, text: yField } },
},
plugins: {
tooltip: { enabled: true },
},
},
};

if (colorField) {
const groups = new Map<string, any[]>();
for (const row of table) {
const key = String(row[colorField] ?? '');
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(toPoint(row));
}
let colorIdx = 0;
for (const [name, data] of groups) {
config.data.datasets.push({
label: name,
data,
backgroundColor: getSeriesBackgroundColor(palette, colorIdx, opacity),
borderColor: getSeriesBorderColor(palette, colorIdx),
borderWidth: 1,
});
colorIdx++;
}
config.options.plugins.legend = { display: true };
} else {
config.data.datasets.push({
data: table.map(toPoint),
backgroundColor: getSeriesBackgroundColor(palette, 0, opacity),
borderColor: getSeriesBorderColor(palette, 0),
borderWidth: 1,
});
config.options.plugins.legend = { display: false };
}

// Stash size metadata so postProcess can rescale radii to the final canvas.
config._sizeField = sizeField ?? null;

Object.assign(spec, config);
delete spec.mark;
delete spec.encoding;
},
properties: [
{ key: 'opacity', label: 'Opacity', type: 'continuous', min: 0.1, max: 1, step: 0.05, defaultValue: 0.6 },
],
postProcess: (option, ctx) => {
if (!option.data?.datasets) return;
const sizeField: string | null = option._sizeField ?? null;
delete option._sizeField;
if (!sizeField) {
// Strip helper field even when no size channel is present.
for (const ds of option.data.datasets) for (const pt of ds.data) delete pt._v;
return;
}

// Collect all raw size values to build a dataset-wide radius scale.
const allValues: number[] = [];
for (const ds of option.data.datasets) {
for (const pt of ds.data) allValues.push(pt._v);
}

// Scale the max bubble radius to the canvas AND point density so dense
// plots don't turn into a blob.
const w = option._width || ctx.canvasSize.width;
const h = option._height || ctx.canvasSize.height;
const minDim = Math.min(w, h);
const count = Math.max(1, allValues.length);
const rMaxByDensity = Math.sqrt((w * h) / count * 0.08);
const rMax = Math.max(8, Math.min(34, Math.round(Math.min(minDim * 0.09, rMaxByDensity))));
const rMin = Math.max(3, Math.round(rMax * 0.22));
const radiusScale = makeRadiusScale(allValues, rMin, rMax);

for (const ds of option.data.datasets) {
for (const pt of ds.data) {
const v = pt._v;
if (typeof v === 'number' && isFinite(v)) pt.r = radiusScale(v);
delete pt._v;
}
}
},
};
3 changes: 2 additions & 1 deletion packages/flint-js/src/chartjs/templates/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import { ChartTemplateDef } from '../../core/types';
import { cjsScatterPlotDef } from './scatter';
import { cjsBubbleChartDef } from './bubble';
import { cjsBarChartDef, cjsStackedBarChartDef, cjsGroupedBarChartDef } from './bar';
import { cjsLineChartDef } from './line';
import { cjsAreaChartDef } from './area';
Expand All @@ -22,7 +23,7 @@ import { cjsRoseChartDef } from './rose';
* Chart.js chart template definitions, grouped by category.
*/
export const cjsTemplateDefs: { [key: string]: ChartTemplateDef[] } = {
'Scatter & Point': [cjsScatterPlotDef],
'Scatter & Point': [cjsScatterPlotDef, cjsBubbleChartDef],
'Bar': [cjsBarChartDef, cjsGroupedBarChartDef, cjsStackedBarChartDef, cjsHistogramDef],
'Line & Area': [cjsLineChartDef, cjsAreaChartDef],
'Part-to-Whole': [cjsPieChartDef],
Expand Down
190 changes: 190 additions & 0 deletions packages/flint-js/src/echarts/templates/calendar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

/**
* ECharts Calendar Heatmap template.
*
* Contrast with VL:
* VL: has no first-class calendar; would fake it with rect + computed
* week/day fields.
* EC: a `calendar` coordinate system + a `heatmap` series bound to it
* (coordinateSystem: 'calendar'), plus a continuous `visualMap`.
*
* Encoding:
* x (temporal) → the date of each cell
* color (quantitative) → the cell value (defaults to a count of 1)
*/

import { ChartTemplateDef, EncodingActionDef } from '../../core/types';
import { getPaletteForScheme } from '../colormap';

/** Sequential color ramps (low → high). Mirrors heatmap's scheme vocabulary. */
const SCHEME_COLORS: Record<string, string[]> = {
viridis: ['#440154', '#3b528b', '#21918c', '#5ec962', '#fde725'],
blues: ['#f7fbff', '#6baed6', '#08519c'],
greens: ['#f7fcf5', '#74c476', '#00441b'],
reds: ['#fff5f0', '#fb6a4a', '#a50f15'],
oranges: ['#fff5eb', '#fd8d3c', '#7f2704'],
purples: ['#fcfbfd', '#9e9ac8', '#3f007d'],
github: ['#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39'],
};

/** Coerce a raw cell key into a 'YYYY-MM-DD' string, regardless of upstream temporal conversion. */
function toDateString(raw: unknown): string | null {
if (raw == null) return null;
let d: Date;
if (raw instanceof Date) {
d = raw;
} else if (typeof raw === 'number' && isFinite(raw)) {
// Treat as epoch (ms if large, seconds otherwise).
d = new Date(raw < 1e12 ? raw * 1000 : raw);
} else {
const s = String(raw).trim();
d = new Date(s);
if (isNaN(d.getTime())) return null;
}
if (isNaN(d.getTime())) return null;
// Use UTC components so the labelled day matches the source date.
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
const day = String(d.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}

export const ecCalendarHeatmapDef: ChartTemplateDef = {
chart: 'Calendar Heatmap',
template: { mark: 'rect', encoding: {} },
channels: ['x', 'color'],
markCognitiveChannel: 'color',
instantiate: (spec, ctx) => {
const { channelSemantics, table, colorDecisions, encodings } = ctx;
const dateField = channelSemantics.x?.field;
const valueField = channelSemantics.color?.field;
if (!dateField) return;

// Aggregate to one value per calendar day (sum when multiple rows share a date).
const cellMap = new Map<string, number>();
for (const row of table) {
const dateStr = toDateString(row[dateField]);
if (!dateStr) continue;
const val = valueField ? (Number(row[valueField]) || 0) : 1;
cellMap.set(dateStr, (cellMap.get(dateStr) ?? 0) + val);
}

const calData: [string, number][] = [];
let minVal = Infinity;
let maxVal = -Infinity;
let minDate = '9999-12-31';
let maxDate = '0000-01-01';
for (const [dateStr, val] of cellMap) {
calData.push([dateStr, val]);
if (val < minVal) minVal = val;
if (val > maxVal) maxVal = val;
if (dateStr < minDate) minDate = dateStr;
if (dateStr > maxDate) maxDate = dateStr;
}
if (calData.length === 0) return;
if (minVal === Infinity) minVal = 0;
if (maxVal === -Infinity) maxVal = 1;
if (minVal === maxVal) maxVal = minVal + 1;

// ── Layout: weeks span the X axis, weekdays the Y axis ───────────────
const dayMs = 86400000;
const spanDays = (Date.parse(maxDate) - Date.parse(minDate)) / dayMs;
const weeks = Math.max(1, Math.ceil((spanDays + 8) / 7));
// Shrink cells when the range is long so the canvas stays reasonable.
const cell = weeks > 60 ? 12 : weeks > 30 ? 15 : 18;
const calLeft = 44; // room for weekday labels
const calRight = 16;
const calTop = 34; // room for the month-label row
// Bottom band for the continuous visualMap. In the gallery `calculable`
// is on, so the colour bar also draws value labels + drag handles above
// it; reserve enough height that none of it rides up into the last
// weekday row of the calendar.
const vmHeight = 70;
const gridH = 7 * cell;

const canvasW = calLeft + weeks * cell + calRight;
const canvasH = calTop + gridH + vmHeight;

// ── Color scheme ─────────────────────────────────────────────────────
const encScheme = encodings?.color?.scheme;
const userScheme = (encScheme && encScheme !== 'default') ? encScheme : undefined;
const schemeName = userScheme || 'viridis';
const decision = colorDecisions?.color ?? colorDecisions?.group;
let schemeColors: string[] = SCHEME_COLORS[schemeName] || SCHEME_COLORS.viridis;
if (decision?.schemeId) {
const fromDecision = getPaletteForScheme(decision.schemeId);
if (fromDecision && fromDecision.length > 0) schemeColors = fromDecision;
}

const option: any = {
tooltip: {
trigger: 'item',
formatter: (params: any) => {
const [date, val] = params.value;
return `${date}<br/>${valueField ?? 'Count'}: ${val}`;
},
},
visualMap: {
type: 'continuous',
min: minVal,
max: maxVal,
calculable: true,
orient: 'horizontal',
left: 'center',
bottom: 6,
itemWidth: 12,
itemHeight: 100,
text: ['high', 'low'],
inRange: { color: schemeColors },
},
calendar: {
top: calTop,
left: calLeft,
right: calRight,
cellSize: [cell, cell],
range: minDate === maxDate ? minDate : [minDate, maxDate],
orient: 'horizontal',
splitLine: { show: true, lineStyle: { color: '#ccc', width: 1 } },
itemStyle: { borderWidth: 1, borderColor: '#fff', color: '#f4f4f4' },
yearLabel: { show: false },
dayLabel: { firstDay: 1, fontSize: 10, color: '#666' },
monthLabel: { fontSize: 11, color: '#333' },
},
series: [{
type: 'heatmap',
coordinateSystem: 'calendar',
data: calData,
}],
_width: canvasW,
_height: canvasH,
};

Object.assign(spec, option);
delete spec.mark;
delete spec.encoding;
},
encodingActions: [
{
key: 'colorScheme',
label: 'Scheme',
isApplicable: (ctx) => !!ctx.encodings.color?.field,
dependencies: ['color'],
control: {
type: 'discrete', options: [
{ value: undefined, label: 'Default (Viridis)' },
{ value: 'viridis', label: 'Viridis' },
{ value: 'github', label: 'GitHub' },
{ value: 'blues', label: 'Blues' },
{ value: 'greens', label: 'Greens' },
{ value: 'reds', label: 'Reds' },
{ value: 'oranges', label: 'Oranges' },
{ value: 'purples', label: 'Purples' },
],
},
get: (enc) => enc.color?.scheme,
set: (enc, value) => ({ ...enc, color: { ...enc.color, scheme: value } }),
},
] as EncodingActionDef[],
};
Loading
Loading