Skip to content
Open

Dev #97

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: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- ECharts categorical legends (and their title graphics) are pinned with
`legend.right` instead of a design-canvas `left` pixel. Hosts that size the
container independently of `_width` and call `chart.resize()` keep the
reserved gutter instead of overlapping the plot or clipping the legend
([#98](https://github.com/microsoft/flint-chart/issues/98)).
- Visible units now require an explicit `unit` in the field's semantic
annotation. Conventional compact units may accompany values, while lexical
units such as `years` are stated once as part of the field title. Bar Tables
also no longer repeat their value column as annotations on the bars.
- A raw sum-stacked chart whose total lands exactly on a clean axis tick now
keeps that edge flush instead of adding an empty interval above it, including
machine-scale residue from calculated shares. Totals meaningfully beyond the
clean endpoint still advance to the next tick; the rule is derived from the
plotted stack and does not special-case percentages or 100.
- Series-end labels now use a bounded screen-space packing pass when endpoints
form one readable column. Small adjustments keep labels attached by proximity;
crowded or horizontally staggered sets fall back together to the next legend
placement instead of leaving a partial or overlapping direct-label system.

## [0.5.1] - 2026-08-13

### Added
Expand Down
15 changes: 14 additions & 1 deletion agent-skills/flint-chart-author/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,20 @@ understates what you know:
}
```

- `unit` — the unit or currency code: `"USD"`, `"°C"`, `"kg"`.
- `unit` — an optional assertion that authorizes Flint to display a unit. Add
it only when the data or surrounding context establishes the measurement
and seeing it materially changes how a reader interprets the number. A type
such as `Duration`, a field name such as `life_expectancy`, or values that
merely look plausible are not enough evidence by themselves.
- Prefer canonical codes: `"USD"`, `"°C"`, `"kg"`, `"km/h"`, `"min"`.
- Conventional compact units are normalized and may appear beside values
(`USD` → `$`, `hours` → `hr`).
- Lexical units such as `"years"` are stated once beside the field name as
`field (years)`, not repeated after every value.
- Do not put explanatory phrases in `unit`. Put qualifications such as
`"per working-age resident"` or `"constant 2024 prices"` in the subtitle.
- Omit `unit` when its meaning, scale, or denominator is uncertain. Flint
does not infer a visible unit from the semantic type or field name.
- `intrinsicDomain` — the field's own bounds, for bounded scales only: `[1, 5]`
for a five-star rating, `[0, 100]` for a percentage score. Not for
open-ended measures.
Expand Down
63 changes: 63 additions & 0 deletions docs/community-backends.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Community backends

Community backends extend Flint to additional renderers and delivery surfaces.
They use the same `ChartAssemblyInput`, but may have different chart coverage,
release cadence, and gallery, editor, MCP, or ThemeSpec integration from Flint's
core backends.

## Image-Charts

> Originally contributed by
> [François-Guillaume Ribreau](https://github.com/FGRibreau).

The Image-Charts backend compiles a Flint input into an unsigned URL for the
third-party [Image-Charts](https://www.image-charts.com/) service. It is useful
when the output must work as an ordinary image URL, including email, generated
documents, chat messages, and other no-JavaScript environments.

```ts
import {
assembleImageCharts,
isImageChartsSupported,
} from 'flint-chart/image-charts';

if (isImageChartsSupported(input.chart_spec.chartType)) {
const artifact = assembleImageCharts(input);
// { type: 'image-charts', url: 'https://image-charts.com/chart?...' }
}
```

Assembly is pure: it creates the URL without making a network request. Loading
the returned URL sends the encoded chart data to Image-Charts, so do not use it
with confidential data unless sending that data to the service is acceptable
under your privacy and deployment requirements.

### Supported charts

- Bar Chart, Grouped Bar Chart, and Stacked Bar Chart
- Line Chart, Sparkline, and Area Chart
- Scatter Plot
- Pie Chart and Donut Chart
- Radar Chart

Unsupported chart types and faceted inputs throw an error rather than silently
falling back to another representation.

### Current scope

- Output is an unsigned `https://image-charts.com/chart?...` GET URL. Account
identifiers, HMAC signatures, and secrets are outside this pure compiler.
- Width and height are clamped to 999 pixels, and total area is clamped to
998,001 pixels, matching the service's documented chart-size limits.
- Data, labels, legends, colors, and titles are carried in the query string.
Large or label-heavy charts can produce long URLs; Flint does not currently
convert them to Image-Charts POST requests or enforce a maximum URL length.
- Banded bar charts use Flint's overflow filtering before URL serialization.
- The backend uses a fixed categorical palette. ThemeSpec and most
`chartProperties` are not applied.
- Flint does not currently render this artifact in its gallery, editor, or MCP
server. Availability, caching, retention, quotas, and subscription behavior
are controlled by Image-Charts.

See the [Image-Charts API documentation](https://documentation.image-charts.com/)
for the hosted service's current request grammar and limits.
5 changes: 4 additions & 1 deletion docs/design-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -656,7 +656,10 @@ Only override native formatting when semantic context adds value: prefix/suffix,
| **Sentiment / Correlation** | `+` + data-driven | — | — | — | Signed decimal |
| **Latitude / Longitude** | — (empty) | — | — | — | VL native |

Unit/currency priority is `annotation.unit` > column-name heuristics > data-value scanning > type defaults.
Visible unit text requires `annotation.unit`; semantic types, column names, and
data values do not authorize display by themselves. Conventional compact units
such as `$`, `%`, `°C`, `kg`, or `min` may accompany values. Lexical units such
as `years` are stated once with the field title (`field (years)`).

**Parsing** is the compiler's job, guided by semantic type rather than stored on context:

Expand Down
Binary file added docs/figs/issue-98-slope-534-before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/figs/issue-98-slope-534.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/figs/issue-98-slope-800-before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/figs/issue-98-slope-800.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion docs/reference-vegalite.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,9 @@ The **Availability** column shows whether a parameter is `always` available or `

**Encoding channels:** `x`, `color`

_No template-specific parameters._
| Parameter | Control | Domain | Default | Availability | Description |
|---|---|---|---|---|---|
| `cornerRadius` | number | 0 – 8 (step 1) | `2` | always | Corner radius for supported marks. |

### ![](chart-icon-bar-table.svg) Bar Table

Expand Down
5 changes: 5 additions & 0 deletions packages/flint-js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@
"import": "./dist/excel/index.js",
"require": "./dist/excel/index.cjs"
},
"./image-charts": {
"types": "./dist/image-charts/index.d.ts",
"import": "./dist/image-charts/index.js",
"require": "./dist/image-charts/index.cjs"
},
"./test-data": {
"types": "./dist/test-data/index.d.ts",
"import": "./dist/test-data/index.js",
Expand Down
38 changes: 38 additions & 0 deletions packages/flint-js/src/core/field-semantics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,44 @@ const UNIT_SUFFIX_MAP: Record<string, string> = {
'%': '%',
};

export interface DisplayUnit {
/** Normalized display text, e.g. `USD` becomes `$` and `hours` becomes `hr`. */
text: string;
/** Compact conventional tags may accompany values; lexical units belong once beside the field name. */
placement: 'value' | 'field';
/** Currency symbols precede values; other compact units follow them. */
position: 'prefix' | 'suffix';
}

/**
* Resolve display intent only from a unit explicitly declared in the semantic
* annotation. A semantic type or suggestive field name is not permission to
* print a unit.
*/
export function resolveDisplayUnit(annotation?: SemanticAnnotation): DisplayUnit | undefined {
const declared = annotation?.unit?.trim();
if (!declared) return undefined;

const currency = CURRENCY_MAP[declared.toUpperCase()] ?? CURRENCY_MAP[declared];
if (currency) return { text: currency, placement: 'value', position: 'prefix' };

const compact = UNIT_SUFFIX_MAP[declared] ?? UNIT_SUFFIX_MAP[declared.toLowerCase()];
if (compact) return { text: compact.trim(), placement: 'value', position: 'suffix' };

// Field-level units are labels, not prose. Reject control characters,
// parenthetical fragments, and long descriptions; those belong in a
// subtitle supplied by the authoring agent.
if (declared.length > 24 || /[\r\n()]/.test(declared)) return undefined;
return { text: declared, placement: 'field', position: 'suffix' };
}

/** Append a field-level unit once, preserving labels that already name it. */
export function titleWithDisplayUnit(title: string, unit?: DisplayUnit): string {
if (unit?.placement !== 'field') return title;
if (title.toLocaleLowerCase().includes(`(${unit.text.toLocaleLowerCase()})`)) return title;
return `${title} (${unit.text})`;
}

/**
* Detect whether percentage data uses 0–1 (fractional) or 0–100 (whole-number)
* representation.
Expand Down
43 changes: 13 additions & 30 deletions packages/flint-js/src/core/theme/ground.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import {
resolvePresenceInk,
sampleRamp,
} from './presence.js';
import { CURRENCY_MAP } from '../field-semantics.js';
import { resolveDisplayUnit } from '../field-semantics.js';
import { getRegistryEntry } from '../type-registry.js';
import { inferValueLabelFormat, longestLabelChars } from './value-label-format.js';
import { deepMerge } from './merge.js';
Expand Down Expand Up @@ -581,26 +581,9 @@ function percentOfWhole(ctx: GroundingContext, channel: string): string | undefi
return n >= 3 && Math.abs(sum - 100) < 0.5 ? '%' : undefined;
}

/**
* The unit a measure is counted in, when the chart already knows it.
*
* Either the annotation says so outright, or the field names it the way a
* person does — `CO₂ (ppm)`, `Unemployment (%)`. Anything longer than a short
* tag is a phrase, not a unit, and belongs in the subtitle.
*/
const UNIT_IN_FIELD_NAME = /\(([^()]{1,6})\)\s*$/;

function unitText(ctx: GroundingContext, channel: string): string | undefined {
function displayUnit(ctx: GroundingContext, channel: string) {
const sem = ctx.channelSemantics?.[channel];
const declared = sem?.semanticAnnotation?.unit;
const field = sem?.field ?? (ctx.positional as any)?.[channel]?.field;
const named = typeof field === 'string' ? field.match(UNIT_IN_FIELD_NAME) : null;
const raw = (typeof declared === 'string' && declared.length > 0 && declared.length <= 6)
? declared
: named?.[1];
if (!raw) return undefined;
// A currency is written with its sign, not its ISO code: `$8`, not `8 USD`.
return CURRENCY_MAP[raw.toUpperCase()] ?? raw;
return resolveDisplayUnit(sem?.semanticAnnotation);
}

/**
Expand Down Expand Up @@ -923,12 +906,14 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe
// reads in shares, whatever the field was measured in.
const unitPolicy = theme.annotation?.unit ?? 'never';
const inFieldUnits = ctx.stacked !== 'normalize' && !ctx.partToWhole;
const unit = role === 'measure' && inFieldUnits ? unitText(ctx, channel) : undefined;
const unitTag = unitPolicy !== 'never' ? unit : undefined;
const unit = role === 'measure' && inFieldUnits ? displayUnit(ctx, channel) : undefined;
const unitTag = unitPolicy !== 'never' && unit?.placement === 'value' ? unit.text : undefined;

// Where the house keeps its axis titles, the title is the natural place
// for the unit — `Weight (lb)` — and the ticks stay bare numbers.
const titleUnit = showTitle && theme.annotation?.unitsInAxisTitle === true ? unit : undefined;
const titleUnit = showTitle && unit && (
unit.placement === 'field' || theme.annotation?.unitsInAxisTitle === true
) ? unit.text : undefined;

// The gap between a label and the plot is the same gap whether or not a
// tick is drawn in it. Where there is one, the tick spans the first part
Expand Down Expand Up @@ -1460,22 +1445,20 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe
const shareUnit = signals.isPartToWhole && !axisStatesUnit
? percentOfWhole(ctx, valueUnitChannel ?? '')
: undefined;
const valueDisplayUnit = displayUnit(ctx, valueUnitChannel ?? '');
const valueUnit = houseStatesUnit
? (unitText(ctx, valueUnitChannel ?? '') ?? shareUnit)
? (valueDisplayUnit?.placement === 'value' ? valueDisplayUnit.text : shareUnit)
: shareUnit;

// A label placed at the mark sits *inside* it, which only works while the
// mark is longer than the label. Below that length the label has to move
// out, and above the point where the mark reaches the end of the scale an
// outside label has nowhere left to go. Grounding is the stage that can
// say where those two lines are.
// out. Outside placement is chart-wide: the backend reserves room instead
// of flipping only the longest mark inward.
let insideMinValue: number | undefined;
let outsideMaxValue: number | undefined;
if (dlShow && measureChannel) {
const span = measureChannel === 'x' ? ctx.layout.subplotWidth : ctx.layout.subplotHeight;
if (valueMaxAbs > 0 && span > 0) {
insideMinValue = (valueLabelWidthPx / span) * valueMaxAbs;
outsideMaxValue = valueMaxAbs - insideMinValue;
}
}

Expand Down Expand Up @@ -1754,7 +1737,6 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe
format: numberFormat,
...(valueUnit ? { unit: valueUnit } : {}),
insideMinValue,
outsideMaxValue,
...(segmentMinShare !== undefined ? { segmentMinShare } : {}),
},
// A house that dots the end of a line is saying where the story stops.
Expand All @@ -1773,6 +1755,7 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe
padding,
density,
plotWidth: ctx.layout.subplotWidth,
plotHeight: ctx.layout.subplotHeight,
xStep: ctx.layout.xStep,
canvasWidth: ctx.canvasSize?.width,
},
Expand Down
8 changes: 2 additions & 6 deletions packages/flint-js/src/core/theme/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -802,11 +802,6 @@ export interface ResolvedDataLabels {
* question about space, not about style.
*/
insideMinValue?: number;
/**
* Above this magnitude the mark reaches the end of the scale, so an
* outside label would fall off the plot. The mirror of `insideMinValue`.
*/
outsideMaxValue?: number;
/**
* The smallest share of the measure axis a stacked segment may occupy and
* still be labelled — a line of text over the plot's extent along that
Expand Down Expand Up @@ -928,11 +923,12 @@ export interface DesignDecisions {
spacing?: number;
preferredColumns?: number;
};
/** `plotWidth`/`xStep` are what the layout settled, so an axis can ask whether its names still fit. */
/** Plot dimensions and step are what layout settled, so realization can test whether annotations fit. */
layout: {
padding: number;
density: 'compact' | 'normal' | 'airy';
plotWidth?: number;
plotHeight?: number;
xStep?: number;
/** The graphic the caller asked for. Wider than `plotWidth` by the axis gutter. */
canvasWidth?: number;
Expand Down
6 changes: 6 additions & 0 deletions packages/flint-js/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -972,6 +972,12 @@ export interface ChartTemplateDef {
*/
ownsValueLabels?: boolean;

/**
* The template already presents values in a dedicated table column, so a
* generic label layer would repeat the same number on the data mark.
*/
suppressValueLabels?: boolean;

/**
* Opt out of a backend's *generic* column/row facet-splitting pass, even
* though the template declares `x`/`y` (so the axis-less `hasAxes` gate
Expand Down
9 changes: 6 additions & 3 deletions packages/flint-js/src/docs/design-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -1162,7 +1162,10 @@ For generic decimal types (Number, Score, Rating, Ratio, Latitude, Longitude), t

**Unit and currency from annotation metadata:** When the LLM provides `unit` in the annotation (e.g., `"unit": "EUR"` for Price, `"unit": "kg"` for Weight), the format spec uses that directly. See §3 for the full annotation schema.

**Fallback priority for units:** annotation.unit > column-name heuristics ("Weight (kg)") > data-value scanning ("$1,234") > type-specific defaults ("$" for Price).
**Visible-unit policy:** only `annotation.unit` authorizes unit text. Semantic
types, column names, and data scanning may inform parsing or other semantic
decisions, but do not cause a unit to be printed. Conventional compact units
may accompany values; lexical units are stated once with the field title.

### 5.1.1 Parsing

Expand Down Expand Up @@ -2070,9 +2073,9 @@ After this phase, all semantic-type-driven decisions flow through the flat `Chan

1. **Unit/domain annotation reliability.** How reliably will the LLM provide `domain` and `unit`? Mitigation strategies:
- (a) Require domain/unit for a small set of types (Rating, Score, Temperature, Price) — reject annotations without them
- (b) Treat domain/unit as best-effort hints — fall back gracefully to data-inferred or type-intrinsic defaults (current proposal)
- (b) Treat domain/unit as best-effort hints, but require an explicit unit annotation before displaying unit text (current policy)
- (c) Prompt the user to confirm/correct LLM-provided annotations in certain cases
- Fallback priority: annotation.unit > column-name heuristics ("Weight (kg)") > data scan ("$1,234") > type defaults
- Visible unit text has no fallback: it requires `annotation.unit`
- Note: `intrinsicDomain` replaces the old `domain` property for clarity

2. **Scale type auto-detection.** Should we auto-switch to log scale when data spans >2 orders of magnitude? This is powerful but can surprise users. Options:
Expand Down
14 changes: 8 additions & 6 deletions packages/flint-js/src/echarts/facet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,13 +525,14 @@ function repositionFacetedLegendBesideGrids(combined: any): void {
const BUFFER = 16;

const rightMost = Math.max(...grids.map((g: any) => (g.left ?? 0) + (g.width ?? 0)));
const { left: _ignoredLeft, ...legendRest } = combined.legend;
void _ignoredLeft;
combined.legend = {
...combined.legend,
left: rightMost + GAP,
...legendRest,
right: BUFFER,
top: combined.legend.top ?? 20,
orient: combined.legend.orient || 'vertical',
align: 'left',
right: undefined,
textStyle: {
fontSize: highCardinality ? 8 : 11,
...(combined.legend.textStyle || {}),
Expand Down Expand Up @@ -565,13 +566,14 @@ function repositionFacetedPolarLegend(combined: any): void {
const r = Number(p?.radius) || 0;
return cx + r;
}));
const { left: _ignoredLeft, ...legendRest } = combined.legend;
void _ignoredLeft;
combined.legend = {
...combined.legend,
left: rightMost + GAP,
...legendRest,
right: BUFFER,
top: combined.legend.top ?? 20,
orient: combined.legend.orient || 'vertical',
align: 'left',
right: undefined,
textStyle: {
fontSize: highCardinality ? 8 : 11,
...(combined.legend.textStyle || {}),
Expand Down
Loading
Loading