@openpresentation/opf-render 0.11.3 → 0.11.5
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.
- package/README.md +8 -4
- package/dist/charts.js +367 -2
- package/dist/font-manifest.js +124 -0
- package/dist/fonts-browser.d.ts +56 -15
- package/dist/fonts-browser.js +24 -16
- package/dist/fonts-node.d.ts +6 -2
- package/dist/fonts-node.js +18 -14
- package/dist/fonts.js +84 -47
- package/dist/lazy-fonts.js +149 -21
- package/dist/script-font-pack.js +19 -7
- package/fonts/playfair-display/OFL.txt +93 -0
- package/fonts/playfair-display/PROVENANCE.json +48 -0
- package/fonts/playfair-display/PlayfairDisplay-Bold.ttf +0 -0
- package/fonts/playfair-display/PlayfairDisplay-BoldItalic.ttf +0 -0
- package/fonts/playfair-display/PlayfairDisplay-Italic.ttf +0 -0
- package/fonts/playfair-display/PlayfairDisplay-Regular.ttf +0 -0
- package/fonts/raleway/OFL.txt +93 -0
- package/fonts/raleway/PROVENANCE.json +48 -0
- package/fonts/raleway/Raleway-Bold.ttf +0 -0
- package/fonts/raleway/Raleway-BoldItalic.ttf +0 -0
- package/fonts/raleway/Raleway-Italic.ttf +0 -0
- package/fonts/raleway/Raleway-Regular.ttf +0 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# OPF Render
|
|
2
2
|
|
|
3
|
-
**Aptos previews with Intos.** The Office pack includes Intos, Intos Display, Intos Narrow and Intos Serif (16 faces, about 12 MB, vendored under `fonts/intos/`, SIL OFL 1.1, pinned to upstream commit fef9315c14da9e4b23b4c3cac8e718998d4e4736 and per-file SHA-256, unmodified from the upstream files). They are the metric-compatible previews for Aptos, Aptos Display, Aptos Narrow and Aptos Serif, the default `aptos` font scheme: 0.000% mean and maximum width difference against Aptos 2.01 in all four styles, with equal vertical metrics. The letter shapes are Intos's own, not Aptos's. The exported PPTX still names Aptos; no Aptos file is bundled or embedded. A registry built without these faces (the base pack) has no Aptos substitute: visual policy falls back to Roboto or Carlito. Like the open families, Intos is `embed: "used"`: it is not in `registry.embeddedFonts` (the 33 eager npm faces), `prepareNodeFonts().options.embeddedFonts` supplies it, and an SVG embeds only the Intos faces its text draws. `registry.lazyFonts` lists the 51 vendored faces (35 open, 16 Intos) with package-relative file and sha256, and `loadBrowserFontRegistry(faces, {lazyFontsBaseUrl})` loads them on demand: `await registry.ensureLazyFonts(presentation)` fetches and verifies only the
|
|
3
|
+
**Aptos previews with Intos.** The Office pack includes Intos, Intos Display, Intos Narrow and Intos Serif (16 faces, about 12 MB, vendored under `fonts/intos/`, SIL OFL 1.1, pinned to upstream commit fef9315c14da9e4b23b4c3cac8e718998d4e4736 and per-file SHA-256, unmodified from the upstream files). They are the metric-compatible previews for Aptos, Aptos Display, Aptos Narrow and Aptos Serif, the default `aptos` font scheme: 0.000% mean and maximum width difference against Aptos 2.01 in all four styles, with equal vertical metrics. The letter shapes are Intos's own, not Aptos's. The exported PPTX still names Aptos; no Aptos file is bundled or embedded. A registry built without these faces (the base pack) has no Aptos substitute: visual policy falls back to Roboto or Carlito. Like the open families, Intos is `embed: "used"`: it is not in `registry.embeddedFonts` (the 33 eager npm faces), `prepareNodeFonts().options.embeddedFonts` supplies it, and an SVG embeds only the Intos faces its text draws. `registry.lazyFonts` lists the 51 vendored faces (35 open, 16 Intos) with package-relative file and sha256, and `loadBrowserFontRegistry(faces, {lazyFontsBaseUrl})` loads them on demand: `await registry.ensureLazyFonts(presentation)` fetches and verifies only the faces the document draws, by family, weight and style (a plain Aptos deck: Intos Display Bold and Intos Regular, 2 files, 1.5 MB; an italic run adds Intos Italic, a bold run Intos Bold; the code face only when a slide has code), then adds them to the document and the registry together so measurement and painting never disagree. `registry.pendingLazyFonts(presentation)` is the synchronous check hosts gate rendering on, and after an edit it reports just the newly needed faces. The faces are those the renderer itself measures and draws (`presentationFaces(presentation, options, registry)`, one layout-and-paint run with a recording measurement), resolved by the registry's own face selection over the faces it holds plus the vendored ones, so the browser loads exactly what Node draws with every vendored face loaded. A document resolves with the `renderSvg` options the host renders with: pass them as `loadBrowserFontRegistry(entries, { renderOptions: { catalogs } })`, or per call (`pendingLazyFonts(presentation, { catalogs })`, `ensureLazyFonts(presentation, { signal, catalogs })`), so a layout or font scheme id that only the host's catalogs know resolves here too; a document that does not resolve throws (rejects) what `renderSvg` throws for it instead of reporting nothing. Node's `prepareNodeFonts` and `loadOfficeFontRegistry` keep loading the whole open pack and Intos eagerly, so nothing there is face level; with `scripts: 'auto'` they accept `renderOptions: { catalogs }` too. Hosts copy the vendored directories next to the page.
|
|
4
4
|
|
|
5
|
-
Version 0.11.3 previews the classic chart types natively, draws deprecated chart ids as their replacement and scales value axes like Office (no API change; the preview of chart slides changes). Version 0.11.2 makes `scripts: 'auto'` load the script faces a font scheme names and adds `details.loadedFaceHasGlyph` to `missing-glyph` (no export change). Version 0.11.1 adds the open replacement font families the policy routes to and complete Red Hat styles (no API change). Version 0.11.0 requires `@openpresentation/opf` ^0.11.2 (cover centering, Intos Aptos previews, lazy font loading, automatic script fonts and glyph fallback); use it with PPTX 0.11.0 and editor 0.10.0 so preview and export resolve the same core. Version 0.10.0 required `@openpresentation/opf` ^0.11.1 (font policy table, script fonts and right-to-left paragraphs, socials, slide-image treatments, formatted slide-number and date furniture with the host `date` option, per-item alignment). See the changelog for the intentional preview-output changes.
|
|
5
|
+
Version 0.11.5 loads vendored preview fonts by face, not by family, and lets the font loaders resolve documents with the host's render options (`presentationFaces`, `lazyFacesNeeded`; `pendingLazyFonts`, `ensureLazyFonts`, `pendingScripts` and `ensureScripts` take `catalogs` and throw or reject with the `renderSvg` error for a document that does not resolve; `registry.lazyFonts` still lists 94 faces). Version 0.11.4 previews the seven chartex chart types natively (the world map as a non-geographic tile grid), keeps the Latin Noto Sans replacement for script schemes under `scripts: 'auto'` in Node, shapes Noto Sans Mongolian again and bundles Raleway and Playfair Display (`registry.lazyFonts` lists 94 faces; no API change). Version 0.11.3 previews the classic chart types natively, draws deprecated chart ids as their replacement and scales value axes like Office (no API change; the preview of chart slides changes). Version 0.11.2 makes `scripts: 'auto'` load the script faces a font scheme names and adds `details.loadedFaceHasGlyph` to `missing-glyph` (no export change). Version 0.11.1 adds the open replacement font families the policy routes to and complete Red Hat styles (no API change). Version 0.11.0 requires `@openpresentation/opf` ^0.11.2 (cover centering, Intos Aptos previews, lazy font loading, automatic script fonts and glyph fallback); use it with PPTX 0.11.0 and editor 0.10.0 so preview and export resolve the same core. Version 0.10.0 required `@openpresentation/opf` ^0.11.1 (font policy table, script fonts and right-to-left paragraphs, socials, slide-image treatments, formatted slide-number and date furniture with the host `date` option, per-item alignment). See the changelog for the intentional preview-output changes.
|
|
6
6
|
|
|
7
7
|
Version 0.9.0 required `@openpresentation/opf` ^0.11.0 and resolved content ColorRef / `variables` through core `resolveColorRef()`. Authored `#RRGGBB` paint stays authored. It retains the existing rendering APIs.
|
|
8
8
|
|
|
@@ -146,9 +146,9 @@ Font schemes select openly licensed Latin families that no Office substitute cov
|
|
|
146
146
|
| Libre Caslon Text | `@expo-google-fonts/libre-caslon-text@0.4.0` (same) | 400, 400 italic, 700 (Baskerville Old Face, Bookman Old Style) |
|
|
147
147
|
| Bitter | `@expo-google-fonts/bitter@0.4.2` (instanced statics named "Bitter"; the OFL reserves "Bitter Pro") | 400, 700, italics (Rockwell) |
|
|
148
148
|
|
|
149
|
-
All are SIL OFL 1.1, and each manifest entry records the SPDX id read from the shipped notice, its Reserved Font Names, the upstream project and copyright line. An OFL Reserved Font Name stops a modified version from using the reserved name, so the families that have one (PT Serif, Lora, Merriweather Sans, Source Sans 3) are the copyright holder's byte-identical files: every face records `upstreamFile { url, sha256 }` with a commit-pinned URL and a sha256 equal to the face's own, and `test/font-licenses.mjs` enforces it. The other families have none, except Bitter (Reserved Font Name "Bitter Pro"); their files are either google/fonts statics (Barlow and Anton: byte-identical, also pinned by `upstreamFile`) or the instanced statics of the `@expo-google-fonts` packages, whose notice and version `PROVENANCE.json` records.
|
|
149
|
+
All are SIL OFL 1.1, and each manifest entry records the SPDX id read from the shipped notice, its Reserved Font Names, the upstream project and copyright line. An OFL Reserved Font Name stops a modified version from using the reserved name, so the families that have one (PT Serif, Lora, Merriweather Sans, Source Sans 3, Raleway, Playfair Display) are the copyright holder's byte-identical files: every face records `upstreamFile { url, sha256 }` with a commit-pinned URL and a sha256 equal to the face's own, and `test/font-licenses.mjs` enforces it. The other families have none, except Bitter (Reserved Font Name "Bitter Pro"); their files are either google/fonts statics (Barlow and Anton: byte-identical, also pinned by `upstreamFile`) or the instanced statics of the `@expo-google-fonts` packages, whose notice and version `PROVENANCE.json` records.
|
|
150
150
|
|
|
151
|
-
|
|
151
|
+
**Raleway** and **Playfair Display** reserve their own names, and Google Fonts serves them only as variable fonts, which the Node raster engine (resvg 2.6.2) draws at one weight (400 and 700 rendered byte-identically, and Raleway's default instance is Thin). Instancing them would be a modified version under the reserved name, so they are bundled as the copyright holders' unmodified static files instead: googlefonts/Raleway at commit `7e0be84` and clauseggers/Playfair at `6e115d7` (tag 1.202), `fonts/TTF`, four styles each, byte-identical with `upstreamFile` pins. **Bitter** (the Rockwell replacement) reserves the name "Bitter Pro". OFL only stops a modified font from carrying its Reserved Font Name in its family or file name, so the instanced statics, named "Bitter", are allowed; `test/font-licenses.mjs` implements exactly that name-contains rule for instanced faces (an instanced face named Carlito, Raleway, Lora or Playfair Display still fails, and a family that must keep its reserved name ships the unmodified upstream file). **Libre Caslon Text** has no bold italic (its static releases and the Google Fonts instances stop at Regular, Italic and Bold): a bold italic request draws the real italic and reports `visual`; bold is never synthesized.
|
|
152
152
|
|
|
153
153
|
Source Sans Pro was renamed Source Sans 3. A request for the old name draws the Source Sans 3 faces through a built-in alias (`renamedFrom` in the manifest) and is reported `visual`, in any substitution policy, because the releases differ (bold advances by about 0.7%). Source Sans 3 itself resolves exact and is also the declared replacement for Candara, Corbel, Gill Sans MT and Seaford. Red Hat Display and Text are the declared replacements for Segoe UI and Tahoma. The RedHatFont repository's own static files cannot be used: the Regular, Light and SemiBold italics (and Red Hat Text Italic) do not set the OS/2 italic bit (only the Bold Italic files do), Red Hat Display SemiBold declares weight 707 and Bold 799 (Red Hat Text Bold declares 700), so in resvg a 600 request paints as Bold and the regular and bold italics paint as one face (probed). The vendored Red Hat faces are therefore the correctly labelled instanced statics of `@expo-google-fonts` (Red Hat has no Reserved Font Name): Segoe UI Semibold draws a real 600 (and 600 italic), and Segoe UI, Segoe UI Light and Tahoma draw real italics, none synthesized. Trade-off: the shipped Red Hat Display Bold (Google Fonts weight 700 instance) is about 2.5% narrower than the RedHatFont Bold it replaces, so the Segoe UI Bold preview moves from +0.6% to -1.8% mean width against Segoe UI Bold 5.71 (mean absolute 0.95% to 1.86%, max 6.0% to 5.5%); the regular widths are identical. A style a family lacks is not synthesized: Bebas Neue and Anton bold draw the regular face and report `visual`, and an italic request throws `font-style-unavailable`, as for Roboto Mono. A test renders every vendored face through resvg and checks that exactly that face paints.
|
|
154
154
|
|
|
@@ -189,6 +189,8 @@ registry.scriptSelection; // { detected, scripts, unavailable, packages, notInst
|
|
|
189
189
|
const fonts = await loadBrowserFontRegistry(baseEntries, { substitutionPolicy: 'visual', fallbackFamily: 'Roboto', scriptBaseUrl: '/script-fonts/' });
|
|
190
190
|
if (fonts.pendingScripts(presentation).length) await fonts.ensureScripts(presentation); // resolves { detected, scripts, unavailable, loaded }
|
|
191
191
|
// pendingScripts is synchronous: empty means nothing is missing, so render at once.
|
|
192
|
+
// Text decides, so the analysis does not resolve layouts or catalogs. Pass the render options (renderOptions: { catalogs } to
|
|
193
|
+
// loadBrowserFontRegistry, or per call) so a font scheme only the host's catalogs have and that names a script font (Yu Gothic) counts.
|
|
192
194
|
```
|
|
193
195
|
|
|
194
196
|
Where the renderer has per-character glyph fallback (`glyphFallbackFamilies`), auto also loads Noto Sans when the text has Greek or Cyrillic (the chosen Latin face may lack it), and the next CJK face of the fallback chain when a drawn Han, kana or Hangul character is missing from the loaded ones (a Simplified-only hanzi in Japanese text, hanja). That is capped at one fallback package beyond the packages the text's own scripts need: a character no CJK face covers never pulls in all four (10 to 20 MiB each); it is listed in `ensureScripts().uncovered` (Node: `script-glyph-uncovered`, `registry.scriptSelection.uncovered`) and the renderer reports `missing-glyph`. Browser loads are all or nothing per package: a failed fetch, hash check or `FontFace.load()` leaves the registry, the document and `loadedScriptPackages` unchanged and the package pending. One failing package does not lose the others: `ensureScripts` loads every package it can, then rejects with an `OPFFontError` whose `details` are `{ loaded, failed: [{package, code, message}] }`, and the next call retries only the failures. `ensureScripts(presentation, { signal })` and `loadScripts(scripts, { signal })` take a per-call `AbortSignal` (the creation signal is not reused), and `dispose()` during a load leaves no face behind and rejects with `font-registry-disposed`. Script faces carry `embed: "used"`, like the open families: the eager `registry.embeddedFonts` leaves them out, and when you pass them explicitly (`registry.selectEmbeddedFonts(face => face.scripts)`) an SVG embeds one only when the slide's text uses its family, so a Latin slide never carries a CJK face.
|
|
@@ -244,6 +246,8 @@ Dependency policy:
|
|
|
244
246
|
|
|
245
247
|
Browser support boundary: `renderSvg`, `renderSvgDeck`, and `resolvePresentation` are browser-importable pure JavaScript APIs. `svgToPng` and `svgToPdf` are Node APIs in this package version because they depend on the Node build of resvg.
|
|
246
248
|
|
|
249
|
+
Chartex chart previews (FF-22b) draw the constructs opf-pptx exports as Office 2016 chartex parts: `treemap` (squarified tiles of the first series, one colour per tile, category labels), `histogram` (a lone value column binned like PowerPoint with Scott's rule count and right-closed bins, or one column per category), `pareto` (columns sorted descending with the cumulative-percentage line on a 0-100% axis), `box-and-whisker` (rows grouped by category, one box per series, exclusive quartiles, whiskers within 1.5 IQR, mean markers, outlier points), `waterfall` (floating bars from the running total, increases and decreases in the first two palette colours, connector lines) and `funnel` (centred bars with value labels). `world` is an honest non-geographic preview: one tile per region shaded by value with its name and value. No geography data is shipped; PowerPoint draws the real map from Bing geodata it fetches itself, so the preview and the native map agree on labels, values and the series colour, not on shapes. Each chartex kind also accepts a lone value column (row numbers as categories; histogram and pareto bin the values). Every mark and label keeps a `data-opf-path` (bins and boxes trace to their value column).
|
|
250
|
+
|
|
247
251
|
Classic chart previews keep finite axis coordinates for subnormal values and values up to `Number.MAX_VALUE`, including mixed positive/negative ranges. At these limits, available numeric precision can merge adjacent axis ticks or prevent extra headroom. Authored values are unchanged. Stacked, percentage or pie/doughnut totals that overflow the finite numeric range are still unsupported: rendering throws a `RangeError` naming the chart path and asking the caller to rescale the values. It does not substitute an empty chart or silently discard the overflowing values.
|
|
248
252
|
|
|
249
253
|
## Development
|
package/dist/charts.js
CHANGED
|
@@ -105,9 +105,21 @@ const RENDERERS = {
|
|
|
105
105
|
pie: renderCircularChart,
|
|
106
106
|
doughnut: renderCircularChart,
|
|
107
107
|
scatter: renderScatterChart,
|
|
108
|
-
radar: renderRadarChart
|
|
108
|
+
radar: renderRadarChart,
|
|
109
|
+
// Chartex constructs (opf-pptx writes them as cx:chartSpace parts, FF-22b).
|
|
110
|
+
treemap: renderTreemapChart,
|
|
111
|
+
histogram: renderHistogramChart,
|
|
112
|
+
pareto: renderHistogramChart,
|
|
113
|
+
box: renderBoxWhiskerChart,
|
|
114
|
+
waterfall: renderWaterfallChart,
|
|
115
|
+
funnel: renderFunnelChart,
|
|
116
|
+
map: renderRegionMapChart
|
|
109
117
|
};
|
|
110
118
|
|
|
119
|
+
// Chartex constructs also accept a lone value column (the exporter plots it
|
|
120
|
+
// against row numbers, or bins it for histogram and pareto).
|
|
121
|
+
const CHARTEX_KINDS = new Set(["treemap", "histogram", "pareto", "box", "waterfall", "funnel", "map"]);
|
|
122
|
+
|
|
111
123
|
/**
|
|
112
124
|
* Render a catalog chart. Returns null when the id is outside the catalog, has
|
|
113
125
|
* no preview renderer, or carries no inline category-major data, so the caller
|
|
@@ -120,7 +132,7 @@ export function renderCatalogChart(item, box, bound, options, svg) {
|
|
|
120
132
|
const data = item.value?.data;
|
|
121
133
|
const rows = Array.isArray(data?.rows) ? data.rows.map((row) => Array.isArray(row) ? row : [row]) : [];
|
|
122
134
|
const columns = Array.isArray(data?.columns) ? data.columns : [];
|
|
123
|
-
if (!render || !rows.length || columns.length < 2) return null;
|
|
135
|
+
if (!render || !rows.length || columns.length < (CHARTEX_KINDS.has(spec.kind) ? 1 : 2)) return null;
|
|
124
136
|
const c = chartContext(item, box, bound, options, svg, rows, columns);
|
|
125
137
|
c.mark("rect", { x: box.x, y: box.y, width: box.width, height: box.height, fill: c.surface, stroke: bound.design.colors.border, "stroke-width": 1 }, item.path);
|
|
126
138
|
render(c, spec);
|
|
@@ -642,3 +654,356 @@ function renderRadarChart(c, spec) {
|
|
|
642
654
|
c.text(label, { x: cx - width - fontPx * 0.2, y: cy - rAt(tick) - c.lineHeight / 2, width, height: c.lineHeight }, c.path, "right");
|
|
643
655
|
}
|
|
644
656
|
}
|
|
657
|
+
|
|
658
|
+
// ---------------------------------------------------------------------------
|
|
659
|
+
// Chartex constructs (FF-22b). opf-pptx writes these as native Office 2016
|
|
660
|
+
// chartex parts (cx:series layoutId treemap, clusteredColumn with binning or
|
|
661
|
+
// aggregation, paretoLine, boxWhisker, waterfall, funnel, regionMap); the
|
|
662
|
+
// previews below draw the same data with the same colours and defaults.
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* The plotted column of a chartex chart: [Category, Value, ...] plots the first
|
|
666
|
+
* value column against its categories; a lone value column is plotted against
|
|
667
|
+
* the row numbers (histogram and pareto bin the values instead).
|
|
668
|
+
*/
|
|
669
|
+
export function chartexValues(rows, columns) {
|
|
670
|
+
const hasCategories = columns.length > 1;
|
|
671
|
+
const column = hasCategories ? 1 : 0;
|
|
672
|
+
const values = rows.map((row) => chartNumber(row[column]));
|
|
673
|
+
const categories = rows.map((row, i) => hasCategories ? (row[0] === null || row[0] === undefined ? "" : String(row[0])) : String(i + 1));
|
|
674
|
+
return { hasCategories, column, values, categories };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Office's automatic histogram bins: Scott's normal reference rule gives the bin
|
|
678
|
+
// width 3.49 * sd * n^(-1/3) (sample standard deviation) over [min, max]; the
|
|
679
|
+
// exporter writes the same count as cx:binCount.
|
|
680
|
+
export function scottBinCount(values) {
|
|
681
|
+
const n = values.length;
|
|
682
|
+
if (n === 0) return 1;
|
|
683
|
+
let min = Infinity, max = -Infinity, mean = 0;
|
|
684
|
+
for (const value of values) { if (value < min) min = value; if (value > max) max = value; mean += value / n; }
|
|
685
|
+
if (!(max > min) || n < 2) return 1;
|
|
686
|
+
let variance = 0;
|
|
687
|
+
for (const value of values) variance += (value - mean) * (value - mean) / (n - 1);
|
|
688
|
+
const width = 3.49 * Math.sqrt(variance) * Math.cbrt(n) ** -1;
|
|
689
|
+
if (!(width > 0) || !Number.isFinite(width)) return 1;
|
|
690
|
+
const count = Math.ceil((max - min) / width - 1e-9);
|
|
691
|
+
return Number.isFinite(count) && count >= 1 ? count : 1;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Equal-width bins from the minimum, closed on the right like PowerPoint's
|
|
696
|
+
* ("[min, e1]", then "(e1, e2]"): each value falls in the last bin whose upper
|
|
697
|
+
* edge it does not exceed. Labels carry six significant digits, and more when
|
|
698
|
+
* two bins would read alike.
|
|
699
|
+
*/
|
|
700
|
+
export function histogramBins(values) {
|
|
701
|
+
const finite = values.filter((value) => value !== null && Number.isFinite(value));
|
|
702
|
+
if (!finite.length) return [];
|
|
703
|
+
const min = finite.reduce((a, b) => Math.min(a, b)), max = finite.reduce((a, b) => Math.max(a, b));
|
|
704
|
+
const count = scottBinCount(finite);
|
|
705
|
+
const edge = (index) => index === 0 ? min : index === count ? max : min / count * (count - index) + max / count * index;
|
|
706
|
+
const bins = Array.from({ length: count }, (_, index) => ({ low: edge(index), high: edge(index + 1), count: 0 }));
|
|
707
|
+
for (const value of finite) {
|
|
708
|
+
let index = 0;
|
|
709
|
+
while (index + 1 < count && value > bins[index].high) index++;
|
|
710
|
+
bins[index].count++;
|
|
711
|
+
}
|
|
712
|
+
for (let precision = 6; ; precision++) {
|
|
713
|
+
const format = (value) => String(Number(value.toPrecision(precision)));
|
|
714
|
+
const labels = bins.map((bin, index) => count === 1 && min === max ? format(min) : `${index === 0 ? "[" : "("}${format(bin.low)}, ${format(bin.high)}]`);
|
|
715
|
+
if (new Set(labels).size === labels.length || precision === 17) return bins.map((bin, index) => ({ ...bin, label: labels[index] }));
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Vertical layout shared by the axis-bearing constructs: category labels below,
|
|
720
|
+
// value ticks on the left (and a percentage axis on the right for pareto).
|
|
721
|
+
function chartexPlot(c, { categories, legendWidth = 0, tickLabels = [], percentAxis = false }) {
|
|
722
|
+
const { box, pad, fontPx } = c;
|
|
723
|
+
const top = box.y + pad + c.lineHeight / 2;
|
|
724
|
+
const bottom = box.y + box.height - pad - c.lineHeight;
|
|
725
|
+
const height = Math.max(1, bottom - top);
|
|
726
|
+
const tickWidth = Math.max(0, ...tickLabels.map((label) => c.width(label))) + fontPx * 0.5;
|
|
727
|
+
const x = box.x + pad + tickWidth;
|
|
728
|
+
const right = box.x + box.width - pad - legendWidth - (percentAxis ? c.width("100%") + fontPx : fontPx / 2);
|
|
729
|
+
const plot = { x, y: top, width: Math.max(1, right - x), height };
|
|
730
|
+
const band = plot.width / Math.max(1, categories.length);
|
|
731
|
+
categories.forEach((entry, i) => c.text(entry.name, { x: plot.x + i * band, y: plot.y + plot.height, width: band, height: c.lineHeight }, entry.path));
|
|
732
|
+
return { plot, band };
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function valueAxis(c, plot, scale) {
|
|
736
|
+
const at = (value) => plot.y + axisFraction(value, scale, true) * plot.height;
|
|
737
|
+
for (const tick of scale.ticks) {
|
|
738
|
+
const y = at(tick);
|
|
739
|
+
gridline(c, plot.x, y, plot.x + plot.width, y);
|
|
740
|
+
c.text(formatTick(tick), { x: c.box.x + c.pad, y: y - c.lineHeight / 2, width: plot.x - c.box.x - c.pad - c.fontPx * 0.35, height: c.lineHeight }, c.path, "right");
|
|
741
|
+
}
|
|
742
|
+
const crossing = at(Math.min(scale.max, Math.max(scale.min, 0)));
|
|
743
|
+
axisLine(c, plot.x, plot.y, plot.x, plot.y + plot.height);
|
|
744
|
+
axisLine(c, plot.x, crossing, plot.x + plot.width, crossing);
|
|
745
|
+
return { at, crossing };
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Histogram (cx:binning or cx:aggregation) and Pareto (sorted columns with the cumulative-percentage line on a percentage axis).
|
|
749
|
+
function renderHistogramChart(c, spec) {
|
|
750
|
+
const pareto = spec.kind === "pareto";
|
|
751
|
+
const { hasCategories, column, values, categories } = chartexValues(c.rows, c.columns);
|
|
752
|
+
let bars;
|
|
753
|
+
if (hasCategories) {
|
|
754
|
+
bars = values.map((value, i) => ({ value, name: categories[i], path: `${c.path}.data.rows.${i}.${column}`, labelPath: `${c.path}.data.rows.${i}.0` })).filter((bar) => bar.value !== null);
|
|
755
|
+
} else {
|
|
756
|
+
bars = histogramBins(values).map((bin) => ({ value: bin.count, name: bin.label, path: `${c.path}.data.columns.${column}`, labelPath: c.path }));
|
|
757
|
+
}
|
|
758
|
+
if (pareto) bars = bars.slice().sort((a, b) => b.value - a.value);
|
|
759
|
+
const total = bars.reduce((sum, bar) => sum + Math.max(0, bar.value), 0);
|
|
760
|
+
if (!Number.isFinite(total)) throw chartAggregateError(c.path, "cumulative total");
|
|
761
|
+
const dataMax = bars.length ? Math.max(...bars.map((bar) => bar.value)) : 1, dataMin = bars.length ? Math.min(0, ...bars.map((bar) => bar.value)) : 0;
|
|
762
|
+
const probe = niceScale(dataMin, dataMax, 10);
|
|
763
|
+
const { plot, band } = chartexPlot(c, { categories: bars.map((bar) => ({ name: bar.name, path: bar.labelPath })), tickLabels: probe.ticks.map((tick) => formatTick(tick)), percentAxis: pareto });
|
|
764
|
+
const scale = niceScale(dataMin, dataMax, maxIntervalsFor(plot.height, c.lineHeight * 1.2));
|
|
765
|
+
const { at, crossing } = valueAxis(c, plot, scale);
|
|
766
|
+
const width = band / 1.06, offset = (band - width) / 2;
|
|
767
|
+
bars.forEach((bar, i) => {
|
|
768
|
+
const y = at(bar.value);
|
|
769
|
+
c.mark("rect", { x: plot.x + i * band + offset, y: Math.min(y, crossing), width, height: Math.abs(crossing - y), fill: c.colors[0] }, bar.path);
|
|
770
|
+
});
|
|
771
|
+
if (pareto && bars.length) {
|
|
772
|
+
// Percentage axis on the right, 0-100%, and the cumulative line through the bar centres.
|
|
773
|
+
const percent = niceScale(0, 1, maxIntervalsFor(plot.height, c.lineHeight * 1.2), { percent: true });
|
|
774
|
+
const right = plot.x + plot.width;
|
|
775
|
+
axisLine(c, right, plot.y, right, plot.y + plot.height);
|
|
776
|
+
for (const tick of percent.ticks) {
|
|
777
|
+
const y = plot.y + axisFraction(tick, percent, true) * plot.height;
|
|
778
|
+
c.text(formatTick(tick, true), { x: right + c.fontPx * 0.35, y: y - c.lineHeight / 2, width: c.box.x + c.box.width - c.pad - right - c.fontPx * 0.35, height: c.lineHeight }, c.path, "left");
|
|
779
|
+
}
|
|
780
|
+
let running = 0;
|
|
781
|
+
const points = bars.map((bar, i) => {
|
|
782
|
+
running += Math.max(0, bar.value);
|
|
783
|
+
const fraction = total ? running / total : 0;
|
|
784
|
+
return `${c.num(plot.x + (i + 0.5) * band)},${c.num(plot.y + axisFraction(fraction, percent, true) * plot.height)}`;
|
|
785
|
+
});
|
|
786
|
+
c.mark("polyline", { points: points.join(" "), fill: "none", stroke: c.colors[1 % c.colors.length], "stroke-width": 2 * c.pt, "stroke-linejoin": "round", "stroke-linecap": "round" }, `${c.path}.data.columns.${column}`);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Waterfall: floating bars from the running total, increases and decreases in the first two palette colours, connector lines between bars.
|
|
791
|
+
function renderWaterfallChart(c) {
|
|
792
|
+
const { column, values, categories } = chartexValues(c.rows, c.columns);
|
|
793
|
+
let running = 0;
|
|
794
|
+
const bars = values.map((value, i) => {
|
|
795
|
+
if (value === null) return null;
|
|
796
|
+
const from = running;
|
|
797
|
+
running += value;
|
|
798
|
+
if (!Number.isFinite(running)) throw chartAggregateError(c.path, "running total");
|
|
799
|
+
return { i, value, from, to: running };
|
|
800
|
+
}).filter(Boolean);
|
|
801
|
+
const extents = bars.flatMap((bar) => [bar.from, bar.to]);
|
|
802
|
+
const dataMin = extents.length ? Math.min(0, ...extents) : 0, dataMax = extents.length ? Math.max(0, ...extents) : 1;
|
|
803
|
+
const probe = niceScale(dataMin, dataMax, 10);
|
|
804
|
+
const { plot, band } = chartexPlot(c, { categories: categories.map((name, i) => ({ name, path: `${c.path}.data.rows.${i}.0` })), tickLabels: probe.ticks.map((tick) => formatTick(tick)) });
|
|
805
|
+
const scale = niceScale(dataMin, dataMax, maxIntervalsFor(plot.height, c.lineHeight * 1.2));
|
|
806
|
+
const { at } = valueAxis(c, plot, scale);
|
|
807
|
+
const width = band / 1.5, offset = (band - width) / 2;
|
|
808
|
+
bars.forEach((bar, k) => {
|
|
809
|
+
const a = at(bar.from), b = at(bar.to);
|
|
810
|
+
const x = plot.x + bar.i * band + offset;
|
|
811
|
+
c.mark("rect", { x, y: Math.min(a, b), width, height: Math.abs(b - a), fill: c.colors[bar.value < 0 ? 1 : 0] }, `${c.path}.data.rows.${bar.i}.${column}`);
|
|
812
|
+
const next = bars[k + 1];
|
|
813
|
+
if (next) c.mark("line", { x1: x + width, y1: b, x2: plot.x + next.i * band + offset, y2: b, stroke: c.axisColor, "stroke-width": c.pt });
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Funnel: centred bars from the top, widths proportional to the value, value labels inside and category labels on the left.
|
|
818
|
+
function renderFunnelChart(c) {
|
|
819
|
+
const { box, pad, fontPx } = c;
|
|
820
|
+
const { column, values, categories } = chartexValues(c.rows, c.columns);
|
|
821
|
+
const count = categories.length;
|
|
822
|
+
const max = Math.max(0, ...values.filter((value) => value !== null));
|
|
823
|
+
const labelWidth = Math.min(box.width * 0.3, Math.max(0, ...categories.map(c.width)) + fontPx * 0.5);
|
|
824
|
+
const plot = { x: box.x + pad + labelWidth + pad / 2, y: box.y + pad, width: Math.max(1, box.width - 2 * pad - labelWidth - pad / 2), height: Math.max(1, box.height - 2 * pad) };
|
|
825
|
+
const band = plot.height / count, height = band / 1.06, offset = (band - height) / 2;
|
|
826
|
+
categories.forEach((name, i) => {
|
|
827
|
+
const y = plot.y + i * band;
|
|
828
|
+
c.text(name, { x: box.x + pad, y, width: labelWidth, height: band }, `${c.path}.data.rows.${i}.0`, "right");
|
|
829
|
+
const value = values[i];
|
|
830
|
+
if (value === null || !(max > 0) || value <= 0) return;
|
|
831
|
+
const width = plot.width * Math.min(1, value / max);
|
|
832
|
+
const x = plot.x + (plot.width - width) / 2;
|
|
833
|
+
c.mark("rect", { x, y: y + offset, width, height, fill: c.colors[0] }, `${c.path}.data.rows.${i}.${column}`);
|
|
834
|
+
c.text(formatTick(value), { x: plot.x, y: y + offset, width: plot.width, height }, `${c.path}.data.rows.${i}.${column}`);
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Squarified treemap layout (Bruls, Huizing and van Wijk): items are laid out
|
|
840
|
+
* in rows along the shorter side, each row closed when adding the next item
|
|
841
|
+
* would worsen its worst aspect ratio. Returns one rectangle per item, in the
|
|
842
|
+
* item order given (null for items without area).
|
|
843
|
+
*/
|
|
844
|
+
export function squarify(items, x, y, width, height) {
|
|
845
|
+
const total = items.reduce((sum, item) => sum + item.value, 0);
|
|
846
|
+
const rects = new Array(items.length).fill(null);
|
|
847
|
+
if (!(total > 0) || !(width > 0) || !(height > 0)) return rects;
|
|
848
|
+
const scale = width * height / total;
|
|
849
|
+
const ordered = items.map((item, index) => ({ area: item.value * scale, index })).filter((item) => item.area > 0).sort((a, b) => b.area - a.area || a.index - b.index);
|
|
850
|
+
let free = { x, y, width, height };
|
|
851
|
+
let row = [];
|
|
852
|
+
const worst = (candidates, side) => {
|
|
853
|
+
const sum = candidates.reduce((s, item) => s + item.area, 0);
|
|
854
|
+
const min = Math.min(...candidates.map((item) => item.area)), max = Math.max(...candidates.map((item) => item.area));
|
|
855
|
+
return Math.max(side * side * max / (sum * sum), sum * sum / (side * side * min));
|
|
856
|
+
};
|
|
857
|
+
const layoutRow = () => {
|
|
858
|
+
const sum = row.reduce((s, item) => s + item.area, 0);
|
|
859
|
+
const horizontal = free.width >= free.height;
|
|
860
|
+
const side = horizontal ? free.height : free.width;
|
|
861
|
+
const thickness = side > 0 ? sum / side : 0;
|
|
862
|
+
let offset = 0;
|
|
863
|
+
for (const item of row) {
|
|
864
|
+
const length = thickness > 0 ? item.area / thickness : 0;
|
|
865
|
+
rects[item.index] = horizontal
|
|
866
|
+
? { x: free.x, y: free.y + offset, width: thickness, height: length }
|
|
867
|
+
: { x: free.x + offset, y: free.y, width: length, height: thickness };
|
|
868
|
+
offset += length;
|
|
869
|
+
}
|
|
870
|
+
free = horizontal
|
|
871
|
+
? { x: free.x + thickness, y: free.y, width: Math.max(0, free.width - thickness), height: free.height }
|
|
872
|
+
: { x: free.x, y: free.y + thickness, width: free.width, height: Math.max(0, free.height - thickness) };
|
|
873
|
+
row = [];
|
|
874
|
+
};
|
|
875
|
+
for (const item of ordered) {
|
|
876
|
+
const side = Math.min(free.width, free.height);
|
|
877
|
+
if (row.length && side > 0 && worst([...row, item], side) > worst(row, side)) layoutRow();
|
|
878
|
+
row.push(item);
|
|
879
|
+
}
|
|
880
|
+
if (row.length) layoutRow();
|
|
881
|
+
return rects;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Treemap: one tile per category from the first value column (positive values only), one palette colour per tile, category labels inside.
|
|
885
|
+
function renderTreemapChart(c) {
|
|
886
|
+
const { box, pad, fontPx } = c;
|
|
887
|
+
const { column, values, categories } = chartexValues(c.rows, c.columns);
|
|
888
|
+
const items = values.map((value) => ({ value: value !== null && value > 0 ? value : 0 }));
|
|
889
|
+
const total = items.reduce((sum, item) => sum + item.value, 0);
|
|
890
|
+
if (!Number.isFinite(total)) throw chartAggregateError(c.path, "tile total");
|
|
891
|
+
const area = { x: box.x + pad, y: box.y + pad, width: Math.max(1, box.width - 2 * pad), height: Math.max(1, box.height - 2 * pad) };
|
|
892
|
+
if (!total) {
|
|
893
|
+
c.text("No positive chart values", area, c.path);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
const rects = squarify(items, area.x, area.y, area.width, area.height);
|
|
897
|
+
rects.forEach((rect, i) => {
|
|
898
|
+
if (!rect || !(rect.width > 0) || !(rect.height > 0)) return;
|
|
899
|
+
c.mark("rect", { x: rect.x, y: rect.y, width: rect.width, height: rect.height, fill: c.colors[i % c.colors.length], stroke: "#FFFFFF", "stroke-width": c.pt }, `${c.path}.data.rows.${i}.${column}`);
|
|
900
|
+
if (rect.width >= fontPx * 2 && rect.height >= c.lineHeight) {
|
|
901
|
+
c.text(categories[i], { x: rect.x + fontPx * 0.25, y: rect.y, width: rect.width - fontPx * 0.5, height: rect.height }, `${c.path}.data.rows.${i}.0`);
|
|
902
|
+
}
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Box statistics as PowerPoint computes them with the exclusive quartile
|
|
908
|
+
* method (Excel QUARTILE.EXC): the rank p(n + 1) interpolates between sorted
|
|
909
|
+
* values and clamps to the extremes, whiskers reach the furthest values within
|
|
910
|
+
* 1.5 IQR of the box, and values beyond are outliers.
|
|
911
|
+
*/
|
|
912
|
+
export function boxStatistics(values) {
|
|
913
|
+
const sorted = values.filter((value) => value !== null && Number.isFinite(value)).sort((a, b) => a - b);
|
|
914
|
+
const n = sorted.length;
|
|
915
|
+
if (!n) return null;
|
|
916
|
+
const quantile = (p) => {
|
|
917
|
+
const rank = p * (n + 1);
|
|
918
|
+
if (rank <= 1) return sorted[0];
|
|
919
|
+
if (rank >= n) return sorted[n - 1];
|
|
920
|
+
const lower = Math.floor(rank), fraction = rank - lower;
|
|
921
|
+
return sorted[lower - 1] + fraction * (sorted[lower] - sorted[lower - 1]);
|
|
922
|
+
};
|
|
923
|
+
const q1 = quantile(0.25), median = quantile(0.5), q3 = quantile(0.75);
|
|
924
|
+
const iqr = q3 - q1;
|
|
925
|
+
const inside = sorted.filter((value) => value >= q1 - 1.5 * iqr && value <= q3 + 1.5 * iqr);
|
|
926
|
+
const mean = sorted.reduce((sum, value) => sum + value, 0) / n;
|
|
927
|
+
return { q1, median, q3, mean, low: inside[0] ?? q1, high: inside[inside.length - 1] ?? q3, outliers: sorted.filter((value) => value < q1 - 1.5 * iqr || value > q3 + 1.5 * iqr) };
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Box and whisker: rows with the same category form one box per series; median line, mean marker and outliers, gap width 100%.
|
|
931
|
+
function renderBoxWhiskerChart(c) {
|
|
932
|
+
// A lone value column has no category column: like the exporter, its values plot against their row numbers (one box per row).
|
|
933
|
+
const lone = c.columns.length === 1;
|
|
934
|
+
const series = lone ? [{ name: c.label(c.columns[0]), column: 0, index: 0, values: c.rows.map((row) => chartNumber(row[0])) }] : c.series();
|
|
935
|
+
const groups = [];
|
|
936
|
+
c.rows.forEach((row, i) => {
|
|
937
|
+
const name = lone ? String(i + 1) : c.label(row[0]);
|
|
938
|
+
let group = groups.find((entry) => entry.name === name);
|
|
939
|
+
if (!group) groups.push(group = { name, first: i, rows: [] });
|
|
940
|
+
group.rows.push(i);
|
|
941
|
+
});
|
|
942
|
+
const boxes = series.map((s) => groups.map((group) => ({ stats: boxStatistics(group.rows.map((i) => s.values[i])), rows: group.rows })));
|
|
943
|
+
const all = boxes.flat().flatMap((entry) => entry.stats ? [entry.stats.low, entry.stats.high, ...entry.stats.outliers] : []);
|
|
944
|
+
const dataMin = all.length ? Math.min(...all) : 0, dataMax = all.length ? Math.max(...all) : 1;
|
|
945
|
+
const legendWidth = seriesLegend(c, series);
|
|
946
|
+
const probe = niceScale(dataMin, dataMax, 10);
|
|
947
|
+
const { plot, band } = chartexPlot(c, { categories: groups.map((group) => ({ name: group.name, path: lone ? c.path : `${c.path}.data.rows.${group.first}.0` })), legendWidth, tickLabels: probe.ticks.map((tick) => formatTick(tick)) });
|
|
948
|
+
const scale = niceScale(dataMin, dataMax, maxIntervalsFor(plot.height, c.lineHeight * 1.2));
|
|
949
|
+
const { at } = valueAxis(c, plot, scale);
|
|
950
|
+
const group = band / 2, width = group / series.length, offset = (band - group) / 2;
|
|
951
|
+
series.forEach((s, j) => {
|
|
952
|
+
const color = c.colors[j % c.colors.length], path = `${c.path}.data.columns.${s.column}`;
|
|
953
|
+
boxes[j].forEach((entry, i) => {
|
|
954
|
+
const stats = entry.stats;
|
|
955
|
+
if (!stats) return;
|
|
956
|
+
const x = plot.x + i * band + offset + j * width, mid = x + width / 2;
|
|
957
|
+
const top = at(stats.q3), bottom = at(stats.q1);
|
|
958
|
+
c.mark("line", { x1: mid, y1: at(stats.high), x2: mid, y2: top, stroke: color, "stroke-width": c.pt }, path);
|
|
959
|
+
c.mark("line", { x1: mid, y1: bottom, x2: mid, y2: at(stats.low), stroke: color, "stroke-width": c.pt }, path);
|
|
960
|
+
c.mark("line", { x1: x + width * 0.25, y1: at(stats.high), x2: x + width * 0.75, y2: at(stats.high), stroke: color, "stroke-width": c.pt }, path);
|
|
961
|
+
c.mark("line", { x1: x + width * 0.25, y1: at(stats.low), x2: x + width * 0.75, y2: at(stats.low), stroke: color, "stroke-width": c.pt }, path);
|
|
962
|
+
c.mark("rect", { x, y: top, width, height: Math.max(c.pt, bottom - top), fill: color }, path);
|
|
963
|
+
c.mark("line", { x1: x, y1: at(stats.median), x2: x + width, y2: at(stats.median), stroke: c.labelColor, "stroke-width": c.pt }, path);
|
|
964
|
+
const r = 2.5 * c.pt, my = at(stats.mean);
|
|
965
|
+
c.mark("path", { d: `M ${c.num(mid - r)} ${c.num(my - r)} L ${c.num(mid + r)} ${c.num(my + r)} M ${c.num(mid - r)} ${c.num(my + r)} L ${c.num(mid + r)} ${c.num(my - r)}`, fill: "none", stroke: c.labelColor, "stroke-width": c.pt }, path);
|
|
966
|
+
for (const value of stats.outliers) {
|
|
967
|
+
const row = entry.rows.find((k) => s.values[k] === value);
|
|
968
|
+
c.mark("circle", { cx: mid, cy: at(value), r: 3 * c.pt, fill: color, stroke: color, "stroke-width": 0.75 * c.pt }, `${c.path}.data.rows.${row}.${s.column}`);
|
|
969
|
+
}
|
|
970
|
+
});
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/** Mix two hex colours (#RRGGBB) by `t` in [0, 1], rounded per channel. */
|
|
975
|
+
export function mixHex(from, to, t) {
|
|
976
|
+
const channel = (hex, i) => parseInt(hex.slice(1 + 2 * i, 3 + 2 * i), 16);
|
|
977
|
+
const clamp = Math.min(1, Math.max(0, t));
|
|
978
|
+
return `#${[0, 1, 2].map((i) => Math.round(channel(from, i) + (channel(to, i) - channel(from, i)) * clamp).toString(16).toUpperCase().padStart(2, "0")).join("")}`;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Region map: an honest non-geographic preview. PowerPoint draws the map from
|
|
982
|
+
// Bing geodata it fetches itself; the preview shows one tile per region, shaded
|
|
983
|
+
// from a light tint to the series colour by value, with the region name and
|
|
984
|
+
// its value, so labels and colours are traceable without shipping geography.
|
|
985
|
+
function renderRegionMapChart(c) {
|
|
986
|
+
const { box, pad, fontPx } = c;
|
|
987
|
+
const { column, values, categories } = chartexValues(c.rows, c.columns);
|
|
988
|
+
const count = categories.length;
|
|
989
|
+
const area = { x: box.x + pad, y: box.y + pad, width: Math.max(1, box.width - 2 * pad), height: Math.max(1, box.height - 2 * pad) };
|
|
990
|
+
const columns = Math.max(1, Math.min(count, Math.round(Math.sqrt(count * area.width / Math.max(1, area.height)))));
|
|
991
|
+
const rowsCount = Math.ceil(count / columns);
|
|
992
|
+
const tileWidth = area.width / columns, tileHeight = area.height / rowsCount;
|
|
993
|
+
const finite = values.filter((value) => value !== null);
|
|
994
|
+
const min = finite.length ? Math.min(...finite) : 0, max = finite.length ? Math.max(...finite) : 0;
|
|
995
|
+
const light = mixHex(/^#[0-9A-Fa-f]{6}$/.test(c.surface) ? c.surface : "#FFFFFF", c.colors[0], 0.15);
|
|
996
|
+
categories.forEach((name, i) => {
|
|
997
|
+
const value = values[i];
|
|
998
|
+
const t = value === null ? 0 : max > min ? (value - min) / (max - min) : 1;
|
|
999
|
+
const x = area.x + (i % columns) * tileWidth, y = area.y + Math.floor(i / columns) * tileHeight;
|
|
1000
|
+
c.mark("rect", { x, y, width: tileWidth, height: tileHeight, fill: value === null ? c.surface : mixHex(light, c.colors[0], t), stroke: "#FFFFFF", "stroke-width": c.pt }, `${c.path}.data.rows.${i}.${column}`);
|
|
1001
|
+
const inset = { x: x + fontPx * 0.25, width: Math.max(1, tileWidth - fontPx * 0.5) };
|
|
1002
|
+
if (tileHeight >= c.lineHeight * 2) {
|
|
1003
|
+
c.text(name, { ...inset, y: y + tileHeight / 2 - c.lineHeight, height: c.lineHeight }, `${c.path}.data.rows.${i}.0`);
|
|
1004
|
+
if (value !== null) c.text(formatTick(value), { ...inset, y: y + tileHeight / 2, height: c.lineHeight }, `${c.path}.data.rows.${i}.${column}`);
|
|
1005
|
+
} else if (tileHeight >= c.lineHeight) {
|
|
1006
|
+
c.text(name, { ...inset, y: y + (tileHeight - c.lineHeight) / 2, height: c.lineHeight }, `${c.path}.data.rows.${i}.0`);
|
|
1007
|
+
}
|
|
1008
|
+
});
|
|
1009
|
+
}
|
package/dist/font-manifest.js
CHANGED
|
@@ -1121,6 +1121,130 @@ export const BUNDLED_FONT_MANIFEST=freeze({
|
|
|
1121
1121
|
}
|
|
1122
1122
|
]
|
|
1123
1123
|
},
|
|
1124
|
+
{
|
|
1125
|
+
"name": "raleway",
|
|
1126
|
+
"vendored": "fonts/raleway",
|
|
1127
|
+
"version": "7e0be846a3097743b9bf442eb8e65bc1fd96c934",
|
|
1128
|
+
"pack": "open",
|
|
1129
|
+
"source": "https://github.com/googlefonts/Raleway/tree/7e0be846a3097743b9bf442eb8e65bc1fd96c934/fonts/TTF",
|
|
1130
|
+
"license": "OFL-1.1",
|
|
1131
|
+
"licenseFile": "OFL.txt",
|
|
1132
|
+
"licenseSha256": "7e946cf1171784d1015279e7dc35f827957a6b5d1f1f659ae0c98e5f5e37ed9b",
|
|
1133
|
+
"upstreamLicenseUrl": "https://raw.githubusercontent.com/googlefonts/Raleway/7e0be846a3097743b9bf442eb8e65bc1fd96c934/OFL.txt",
|
|
1134
|
+
"reservedFontNames": [
|
|
1135
|
+
"Raleway"
|
|
1136
|
+
],
|
|
1137
|
+
"upstream": "http://scripts.sil.org/OFL",
|
|
1138
|
+
"copyright": "Copyright 2010 The Raleway Project Authors (impallari@gmail.com), with Reserved Font Name \"Raleway\".",
|
|
1139
|
+
"faces": [
|
|
1140
|
+
{
|
|
1141
|
+
"file": "Raleway-Regular.ttf",
|
|
1142
|
+
"family": "Raleway",
|
|
1143
|
+
"weight": 400,
|
|
1144
|
+
"italic": false,
|
|
1145
|
+
"sha256": "cb70f8815eecd850263d54fa59286377b1d7362bec612e981155a00039d8b513",
|
|
1146
|
+
"upstreamFile": {
|
|
1147
|
+
"url": "https://raw.githubusercontent.com/googlefonts/Raleway/7e0be846a3097743b9bf442eb8e65bc1fd96c934/fonts/TTF/Raleway-Regular.ttf",
|
|
1148
|
+
"sha256": "cb70f8815eecd850263d54fa59286377b1d7362bec612e981155a00039d8b513"
|
|
1149
|
+
}
|
|
1150
|
+
},
|
|
1151
|
+
{
|
|
1152
|
+
"file": "Raleway-Italic.ttf",
|
|
1153
|
+
"family": "Raleway",
|
|
1154
|
+
"weight": 400,
|
|
1155
|
+
"italic": true,
|
|
1156
|
+
"sha256": "63f52b6d8e88afa43d94162147ff55846f28af630d834018de5cb7ccc75a8ee8",
|
|
1157
|
+
"upstreamFile": {
|
|
1158
|
+
"url": "https://raw.githubusercontent.com/googlefonts/Raleway/7e0be846a3097743b9bf442eb8e65bc1fd96c934/fonts/TTF/Raleway-Italic.ttf",
|
|
1159
|
+
"sha256": "63f52b6d8e88afa43d94162147ff55846f28af630d834018de5cb7ccc75a8ee8"
|
|
1160
|
+
}
|
|
1161
|
+
},
|
|
1162
|
+
{
|
|
1163
|
+
"file": "Raleway-Bold.ttf",
|
|
1164
|
+
"family": "Raleway",
|
|
1165
|
+
"weight": 700,
|
|
1166
|
+
"italic": false,
|
|
1167
|
+
"sha256": "6894481ab1c298e9c66f23455ecf05ccb25393d719a6b2d63428358c0409d2d0",
|
|
1168
|
+
"upstreamFile": {
|
|
1169
|
+
"url": "https://raw.githubusercontent.com/googlefonts/Raleway/7e0be846a3097743b9bf442eb8e65bc1fd96c934/fonts/TTF/Raleway-Bold.ttf",
|
|
1170
|
+
"sha256": "6894481ab1c298e9c66f23455ecf05ccb25393d719a6b2d63428358c0409d2d0"
|
|
1171
|
+
}
|
|
1172
|
+
},
|
|
1173
|
+
{
|
|
1174
|
+
"file": "Raleway-BoldItalic.ttf",
|
|
1175
|
+
"family": "Raleway",
|
|
1176
|
+
"weight": 700,
|
|
1177
|
+
"italic": true,
|
|
1178
|
+
"sha256": "57eb1a701136eca30b52a4847edcec93daa53f0b5597dd98dbc0b07f475ce44a",
|
|
1179
|
+
"upstreamFile": {
|
|
1180
|
+
"url": "https://raw.githubusercontent.com/googlefonts/Raleway/7e0be846a3097743b9bf442eb8e65bc1fd96c934/fonts/TTF/Raleway-BoldItalic.ttf",
|
|
1181
|
+
"sha256": "57eb1a701136eca30b52a4847edcec93daa53f0b5597dd98dbc0b07f475ce44a"
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
]
|
|
1185
|
+
},
|
|
1186
|
+
{
|
|
1187
|
+
"name": "playfair-display",
|
|
1188
|
+
"vendored": "fonts/playfair-display",
|
|
1189
|
+
"version": "6e115d70ee7ff6ed887babf3829ccd1de65fd2bd",
|
|
1190
|
+
"pack": "open",
|
|
1191
|
+
"source": "https://github.com/clauseggers/Playfair/tree/6e115d70ee7ff6ed887babf3829ccd1de65fd2bd/fonts/TTF",
|
|
1192
|
+
"license": "OFL-1.1",
|
|
1193
|
+
"licenseFile": "OFL.txt",
|
|
1194
|
+
"licenseSha256": "e414e07f7a4da16ed47ebec80d52dd79f21d3ca42bc92385a755d8078a038814",
|
|
1195
|
+
"upstreamLicenseUrl": "https://raw.githubusercontent.com/clauseggers/Playfair/6e115d70ee7ff6ed887babf3829ccd1de65fd2bd/OFL.txt",
|
|
1196
|
+
"reservedFontNames": [
|
|
1197
|
+
"Playfair Display"
|
|
1198
|
+
],
|
|
1199
|
+
"upstream": "https://github.com/clauseggers/Playfair-Display",
|
|
1200
|
+
"copyright": "Copyright 2017 The Playfair Display Project Authors (https://github.com/clauseggers/Playfair-Display), with Reserved Font Name \"Playfair Display\".",
|
|
1201
|
+
"faces": [
|
|
1202
|
+
{
|
|
1203
|
+
"file": "PlayfairDisplay-Regular.ttf",
|
|
1204
|
+
"family": "Playfair Display",
|
|
1205
|
+
"weight": 400,
|
|
1206
|
+
"italic": false,
|
|
1207
|
+
"sha256": "70ef3ee8de32dc81c39f2733a4a2a050c7e6ba78a2f1632f9d5c6cfd482c3abb",
|
|
1208
|
+
"upstreamFile": {
|
|
1209
|
+
"url": "https://raw.githubusercontent.com/clauseggers/Playfair/6e115d70ee7ff6ed887babf3829ccd1de65fd2bd/fonts/TTF/PlayfairDisplay-Regular.ttf",
|
|
1210
|
+
"sha256": "70ef3ee8de32dc81c39f2733a4a2a050c7e6ba78a2f1632f9d5c6cfd482c3abb"
|
|
1211
|
+
}
|
|
1212
|
+
},
|
|
1213
|
+
{
|
|
1214
|
+
"file": "PlayfairDisplay-Italic.ttf",
|
|
1215
|
+
"family": "Playfair Display",
|
|
1216
|
+
"weight": 400,
|
|
1217
|
+
"italic": true,
|
|
1218
|
+
"sha256": "30a7eb5a9309754d4b3e205e6435ef07d80e1b86735774b2ace427f6ed61f991",
|
|
1219
|
+
"upstreamFile": {
|
|
1220
|
+
"url": "https://raw.githubusercontent.com/clauseggers/Playfair/6e115d70ee7ff6ed887babf3829ccd1de65fd2bd/fonts/TTF/PlayfairDisplay-Italic.ttf",
|
|
1221
|
+
"sha256": "30a7eb5a9309754d4b3e205e6435ef07d80e1b86735774b2ace427f6ed61f991"
|
|
1222
|
+
}
|
|
1223
|
+
},
|
|
1224
|
+
{
|
|
1225
|
+
"file": "PlayfairDisplay-Bold.ttf",
|
|
1226
|
+
"family": "Playfair Display",
|
|
1227
|
+
"weight": 700,
|
|
1228
|
+
"italic": false,
|
|
1229
|
+
"sha256": "898a01a59214578e72012131ef4c346844c1fd35613f4ee113a37d61a094a2d0",
|
|
1230
|
+
"upstreamFile": {
|
|
1231
|
+
"url": "https://raw.githubusercontent.com/clauseggers/Playfair/6e115d70ee7ff6ed887babf3829ccd1de65fd2bd/fonts/TTF/PlayfairDisplay-Bold.ttf",
|
|
1232
|
+
"sha256": "898a01a59214578e72012131ef4c346844c1fd35613f4ee113a37d61a094a2d0"
|
|
1233
|
+
}
|
|
1234
|
+
},
|
|
1235
|
+
{
|
|
1236
|
+
"file": "PlayfairDisplay-BoldItalic.ttf",
|
|
1237
|
+
"family": "Playfair Display",
|
|
1238
|
+
"weight": 700,
|
|
1239
|
+
"italic": true,
|
|
1240
|
+
"sha256": "bd3b48ca605438de4337d05ece1ecb9e4149a975d3207174f28e02098bbec3ff",
|
|
1241
|
+
"upstreamFile": {
|
|
1242
|
+
"url": "https://raw.githubusercontent.com/clauseggers/Playfair/6e115d70ee7ff6ed887babf3829ccd1de65fd2bd/fonts/TTF/PlayfairDisplay-BoldItalic.ttf",
|
|
1243
|
+
"sha256": "bd3b48ca605438de4337d05ece1ecb9e4149a975d3207174f28e02098bbec3ff"
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
]
|
|
1247
|
+
},
|
|
1124
1248
|
{
|
|
1125
1249
|
"name": "barlow",
|
|
1126
1250
|
"vendored": "fonts/barlow",
|