diff --git a/packages/preview/primaviz/0.12.0/LICENSE b/packages/preview/primaviz/0.12.0/LICENSE new file mode 100644 index 0000000000..507974aa3b --- /dev/null +++ b/packages/preview/primaviz/0.12.0/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 phiat + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/preview/primaviz/0.12.0/README.md b/packages/preview/primaviz/0.12.0/README.md new file mode 100644 index 0000000000..9a838e32e7 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/README.md @@ -0,0 +1,411 @@ +# Primaviz + +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![Typst version 0.13.0 or higher](https://img.shields.io/badge/typst-%3E%3D0.13.0-239dad)](https://github.com/typst/typst) +[![50+ chart types](https://img.shields.io/badge/chart_types-50%2B-orange)](screenshots/) +[![Pure Typst](https://img.shields.io/badge/dependencies-zero-brightgreen)](https://github.com/phiat/primaviz) + +A charting library for [Typst](https://github.com/typst/typst) built entirely with native primitives (`rect`, `circle`, `line`, `polygon`, `place`). No external dependencies required. + +## Gallery + +All 50+ chart types across 8 pages — see [`examples/showcase.typ`](examples/showcase.typ) for the source: + +![Showcase: bar, horizontal bar, grouped bar, stacked bar, line, multi-line, area, and stacked area charts](screenshots/showcase/showcase-1.png) +![Showcase: pie, donut, radar, scatter, multi-scatter, bubble, gauge, progress-bar, and circular-progress charts](screenshots/showcase/showcase-2.png) +![Showcase: ring progress, dual-axis, histogram, waterfall, funnel, box-plot, heatmap, and correlation-matrix charts](screenshots/showcase/showcase-3.png) +![Showcase: calendar heatmap, progress bars, sparklines, treemap, sankey, lollipop, horizontal lollipop, and diverging bar charts](screenshots/showcase/showcase-4.png) +![Showcase: slope, bullet, grouped-stacked bar, gantt, waffle, bump, dumbbell, and radial-bar charts](screenshots/showcase/showcase-5.png) +![Showcase: sunburst, metric cards, violin plot, timeline, parliament, chord diagram, and dashboard components (card, table, alert, badge)](screenshots/showcase/showcase-6.png) +![Showcase: golden-ratio φ-scaling demo with bar and line charts at 0.5×, 1×, and 2× base size](screenshots/showcase/showcase-7.png) +![Showcase: full-page word cloud of data visualization vocabulary](screenshots/showcase/showcase-8.png) + +## Examples + +| File | Description | +|---|---| +| [`examples/demos/`](examples/demos/) | 21 per-chart demo files, each a 2×2 grid (light/dark + variations) | +| [`examples/showcase.typ`](examples/showcase.typ) | Compact 8-page showcase of all chart types (dark theme) | +| [`examples/demo.typ`](examples/demo.typ) | Comprehensive demo with all features, themes, and data loading | + +Shared datasets in [`data/`](data/) used by both demo and showcase: +- [`data/sales.json`](data/sales.json) — SaaS startup metrics +- [`data/codebase.json`](data/codebase.json) — Open source project stats +- [`data/league.json`](data/league.json) — Soccer league data +- [`data/rpg.json`](data/rpg.json) — D&D campaign tracker +- [`data/words.json`](data/words.json) — Data visualization vocabulary + +```bash +typst compile --root . examples/showcase.typ # Compile the showcase +typst compile --root . examples/demo.typ # Compile the comprehensive demo +typst compile --root . examples/demos/demo-bar.typ # Compile one per-chart demo +``` + +## Features + +- **50+ chart types** for data visualization +- **JSON data input** — load data directly from JSON files +- **Theme system** — golden-ratio proportional scaling from two seeds (`base-size`, `base-gap`), preset themes, custom overrides, and `with-theme()` for document-wide defaults +- **Nice ticks** — D3-style axis tick algorithm produces round, evenly-spaced values; auto-detects decimal precision from step size +- **Smart label placement** — automatic fit detection, font shrinking, and spreading of overlapping labels +- **Layout primitives** — shared utilities for label density, font scaling, and label placement +- **Annotations** — overlay reference lines, bands, labels, content, points, error bars, and rectangles on any Cartesian chart +- **Error bars** — native `errors:` parameter on bar and line charts (symmetric or asymmetric) +- **Outliers** — native `outliers:` field on box-plot boxes +- **Line interpolation** — `line-interpolation:` ("linear", "smooth", "catmull-rom") and `smooth-radius:` (0 to 5, default 0: a "smooth" curve passes through the data points; 1 to 5 draw a moving-average trend; markers, values and error bars always sit on the data) on line and multi-line charts +- **Relative widths** — use `width: 100%` for responsive charts inside containers and grids +- **Dashboard primitives** — `card`, `compact-table`, `alert`, `badge`, `separator`, and `dashboard-layout` for report layouts +- **Customizable** — colors, sizes, labels, legends +- **Pure Typst** — no packages or external tools needed + +## Chart Types + +### Bar Charts +- `bar-chart` - Vertical bar chart +- `horizontal-bar-chart` - Horizontal bar chart +- `grouped-bar-chart` - Side-by-side grouped bars +- `stacked-bar-chart` - Stacked bar segments +- `grouped-stacked-bar-chart` - Groups of stacked segments side by side +- `lollipop-chart` - Vertical stem + dot (cleaner bar alternative) +- `horizontal-lollipop-chart` - Horizontal stem + dot +- `diverging-bar-chart` - Left/right bars from center axis + +### Line & Area Charts +- `line-chart` - Single line with points +- `multi-line-chart` - Multiple series comparison +- `dual-axis-chart` - Two independent Y-axes +- `area-chart` - Filled area under line +- `stacked-area-chart` - Stacked area series + +### Circular Charts +- `pie-chart` - Pie chart with legend +- `pie-chart` (donut mode) - Donut/ring chart +- `radar-chart` - Spider/radar chart + +### Scatter & Bubble +- `scatter-plot` - X/Y point plotting +- `multi-scatter-plot` - Multi-series scatter +- `bubble-chart` - Scatter with size dimension, smart label deconfliction (inside/outside with leaders) +- `multi-bubble-chart` - Multi-series bubble chart + +### Gauges & Progress +- `gauge-chart` - Semi-circular dial gauge +- `progress-bar` - Horizontal progress bar +- `circular-progress` - Ring progress indicator +- `ring-progress` - Concentric fitness rings (Apple Watch style) +- `progress-bars` - Multiple comparison bars, one colour by default (`color: auto` gives each bar its own palette colour) + +### Sparklines (inline) +- `sparkline` - Tiny line chart for tables and text +- `sparkbar` - Tiny bar chart +- `sparkdot` - Tiny dot chart + +### Heatmaps +- `heatmap` - Grid heatmap with color scale +- `calendar-heatmap` - GitHub-style activity grid +- `correlation-matrix` - Symmetric correlation display + +### Statistical +- `histogram` - Auto-binned frequency distribution +- `waterfall-chart` - Bridge/waterfall chart with pos/neg/total segments +- `funnel-chart` - Conversion funnel with percentages, auto external labels for narrow sections (one colour; `color-by-stage: true` for a colour per stage) +- `box-plot` - Box-and-whisker distribution plot +- `treemap` - Nested rectangles for hierarchical data +- `slope-chart` - Two-period comparison with connecting lines +- `bullet-chart` - Compact gauge with qualitative ranges and target +- `bullet-charts` - Multiple bullet charts stacked vertically + +### Proportional & Hierarchical +- `waffle-chart` - 10×10 grid of colored squares for proportions (category names sit beside their squares; a legend when they do not fit) +- `sunburst-chart` - Multi-level hierarchical pie with nested rings +- `parliament-chart` - Semicircle dot layout for seat visualization; each party takes one wedge (`fill-order: "rows"` fills the concentric rows instead) + +### Comparison & Ranking +- `bump-chart` - Multi-period ranking chart +- `dumbbell-chart` - Before/after dot comparisons with connecting lines +- `radial-bar-chart` - Circular bars radiating from center + +### Distribution +- `violin-plot` - Kernel density estimation with mirrored polygon + +### Flow & Timeline +- `sankey-chart` - Flow diagram with curved bands between nodes +- `gantt-chart` - Timeline bar chart for project scheduling (one colour, or one per `group`; `color-by-task: true` for a colour per task) +- `timeline-chart` - Vertical event timeline with alternating layout +- `chord-diagram` - Circular flow diagram with chord bands + +### Dashboard +- `metric-card` - KPI tile with value, delta, and sparkline +- `metric-row` - Horizontal row of metric cards +- `card` - Themed container with optional title and description +- `compact-table` - Dense data table with header styling and highlight column +- `alert` - Info/warning/error/success notification block with left border accent +- `badge` - Inline colored pill (default/secondary/destructive/outline/success) +- `separator` - Themed horizontal rule +- `dashboard-layout` - Grid layout helper for multi-row dashboard pages +- `word-cloud` - Weighted text layout sized by importance; shrinks to keep every word, one hue pale to full by weight (`color: auto` gives each word its own palette colour) + +### Annotations +Overlay extra elements on any cartesian chart (bar, line, area, scatter, bubble, box-plot, histogram, violin, waterfall, dual-axis, ...) via the `annotations:` parameter. All coordinates are in data space. + +- `h-line` — horizontal reference line (target, average, threshold) +- `v-line` — vertical reference line +- `h-band` — horizontal shaded region (goal zone, range) +- `label` — text label at a data point +- `content` — place arbitrary Typst content at (x, y); `anchor:` controls positioning (`top-left`, `top`, `center`, `bottom-right`, etc.) +- `point` — circle marker at (x, y) with configurable `radius`, `fill`, `stroke` +- `errorbar` — vertical (default) or horizontal (`orientation: "h"`) error bar with caps +- `rect` — rectangle in data coordinates with `fill`, `stroke`, `opacity` + +```typst +#bar-chart(data, annotations: ( + (type: "h-line", value: 160, dash: "dashed", label: "target"), + (type: "content", x: 2, y: 165, body: [★ best], anchor: "bottom"), + (type: "errorbar", x: 0, low: 115, high: 125), +)) +``` + +### Error bars (native) + +Bar and line charts accept an `errors:` parameter. Axis range auto-extends to cover the error extremes. + +```typst +#bar-chart(data, errors: (10, 12, 8, 15)) // symmetric +#line-chart(data, errors: ((0.5, 0.7), (0.3, 0.4), (1, 2))) // asymmetric (low, high) +``` + +### Box-plot outliers + +Each box can carry an optional `outliers:` array. Outlier values are drawn as small dots and extend the y-axis range. + +```typst +#box-plot(( + labels: ("A", "B"), + boxes: ( + (min: 20, q1: 35, median: 50, q3: 65, max: 80, outliers: (95, 5)), + (min: 15, q1: 30, median: 45, q3: 60, max: 75), + ), +)) +``` + +## Installation + +```typst +#import "@preview/primaviz:0.12.0": * +``` + +## Usage + +```typst +#import "@preview/primaviz:0.12.0": * + +// Load data from JSON +#let data = json("mydata.json") + +// Create a bar chart +#bar-chart( + ( + labels: ("A", "B", "C", "D"), + values: (25, 40, 30, 45), + ), + width: 300pt, + height: 200pt, + title: "My Chart", +) + +// Create a pie chart +#pie-chart( + ( + labels: ("Red", "Blue", "Green"), + values: (30, 45, 25), + ), + size: 150pt, + donut: true, +) + +// Create a radar chart +#radar-chart( + ( + labels: ("STR", "DEX", "CON", "INT", "WIS", "CHA"), + series: ( + (name: "Fighter", values: (18, 12, 16, 10, 13, 8)), + (name: "Wizard", values: (8, 14, 12, 18, 15, 11)), + ), + ), + size: 200pt, + title: "Character Comparison", +) +``` + +## Theming + +Every chart function accepts an optional `theme` parameter. Themes control colors, font sizes, grid lines, backgrounds, and other visual properties. + +### Using a preset theme + +```typst +#import "@preview/primaviz:0.12.0": * + +#bar-chart(data, theme: themes.dark) +``` + +### Document-wide theme + +Use `with-theme()` to set a default theme for all charts in a block — no need to pass `theme:` to every chart. Explicit per-chart `theme:` parameters still override. + +```typst +// Block wrapper +#with-theme(themes.dark)[ + #bar-chart(data) // uses dark theme + #pie-chart(data2) // uses dark theme + #line-chart(data3, theme: themes.minimal) // explicit override wins +] + +// Or as a show rule for the entire document +#show: with-theme.with(themes.dark) +``` + +### Scaling with seeds + +All font sizes and spacing are derived from two seed values via golden-ratio (φ = 1.618) powers. Change the seeds to scale everything proportionally: + +```typst +#bar-chart(data, theme: (base-size: 10pt, base-gap: 8pt)) // larger text and spacing +#bar-chart(data, theme: (base-size: 5pt, base-gap: 3pt)) // compact +``` + +### Custom overrides + +Pass a dictionary with only the keys you want to change. Unspecified keys fall back to the active theme (global or default). Partial overrides merge onto the global theme set by `with-theme()`, so `theme: (show-grid: true)` inside a `with-theme(themes.dark)` block gives you dark + grid: + +```typst +#bar-chart(data, theme: (show-grid: true, palette: (red, blue, green))) +#bar-chart(data, theme: (tick-digits: 2)) // force 2 decimal places on axis ticks +#bar-chart(data, theme: (category-label-min-size: 7pt)) // smallest type for crowded category labels (6pt, or 5pt on horizontal bar rows, unless set) +``` + +Category labels are all kept whenever that is possible at a legible size: flat, else tilted 45 degrees or upright (whichever keeps the larger type, down to `category-label-min-size`), and only then thinned evenly (first and last always shown). Horizontal bar and lollipop charts grow their rows with `height: auto`; with a fixed height they shrink the row labels to the floor before thinning. + +### Theme from JSON + +Build a theme from a JSON tokens file (e.g., exported from a CSS design system): + +```typst +#let tokens = json("tokens.json") +#let my-theme = theme-from-json(tokens.light) +#let my-dark = theme-from-json(tokens.dark) + +#show: with-theme.with(my-theme) +``` + +Expected JSON format: `palette` (array of hex strings), `text-color`, `text-color-light`, `text-color-inverse`, `background` (hex or null), `border-color`, `border-radius` (number in pt). + +### Extracting themes from CSS + +A pair of helper scripts (Python and TypeScript) convert CSS design-token files (Tailwind, shadcn, etc.) into primaviz themes. They live in the repo but are not shipped with the package — see [CONTRIBUTING.md](https://github.com/phiat/primaviz/blob/v0.12.0/CONTRIBUTING.md#extracting-themes-from-css) for usage. + +### Custom theme keys + +Themes support passthrough of custom keys not in the default theme. This lets you extend the theme system for your own components: + +```typst +#let my-theme = ( + palette: (red, blue, green), + card-fill: rgb("#f5f5f5"), // custom key — preserved and accessible +) +#show: with-theme.with(my-theme) +``` + +### Available presets + +| Preset | Description | +|---|---| +| `themes.default` | Tableau 10 color palette, no grid, standard font sizes | +| `themes.minimal` | Lighter axis strokes, grid enabled, regular-weight titles | +| `themes.dark` | Dark background (`#1a1a2e`), vibrant neon palette (cyan, pink, purple, ...) | +| `themes.presentation` | Larger font sizes across the board for slides and projectors | +| `themes.print` | Grayscale palette with grid lines, optimized for black-and-white printing | +| `themes.accessible` | Okabe-Ito colorblind-safe palette | +| `themes.compact` | Smaller fonts, tighter padding for dense dashboard layouts | + +## Data Formats + +### Simple data (labels + values) +```typst +( + labels: ("Jan", "Feb", "Mar"), + values: (100, 150, 120), +) +``` + +### Multi-series data +```typst +( + labels: ("Q1", "Q2", "Q3"), + series: ( + (name: "Product A", values: (100, 120, 140)), + (name: "Product B", values: (80, 90, 110)), + ), +) +``` + +### Content labels + +Anywhere a label or series name is accepted, content works as well as a string — +math, markup, or both: + +```typst +#stacked-bar-chart(( + labels: ($alpha$, $beta$), + series: ( + (name: $x$, values: (90, 0)), + (name: [*y*], values: (10, 100)), + ), +)) +``` + +### Scatter/bubble data +```typst +( + x: (1, 2, 3, 4, 5), + y: (10, 25, 15, 30, 20), + size: (5, 10, 8, 15, 12), // for bubble chart +) +``` + +### Heatmap data +```typst +( + rows: ("Row1", "Row2", "Row3"), + cols: ("Col1", "Col2", "Col3"), + values: ( + (1, 2, 3), + (4, 5, 6), + (7, 8, 9), + ), +) +``` + +## Color Palette + +The default theme uses Tableau 10 colors. You can access colors from any theme via the `get-color` function: + +```typst +#import "@preview/primaviz:0.12.0": get-color, themes + +// Default palette +#get-color(themes.default, 0) // blue +#get-color(themes.default, 1) // orange +#get-color(themes.default, 2) // red + +// Or use a theme preset +#get-color(themes.dark, 0) // cyan +``` + +## Contributing + +For local dev setup, project structure, and helper scripts, see [CONTRIBUTING.md](https://github.com/phiat/primaviz/blob/v0.12.0/CONTRIBUTING.md). + +## License + +MIT diff --git a/packages/preview/primaviz/0.12.0/data/codebase.json b/packages/preview/primaviz/0.12.0/data/codebase.json new file mode 100644 index 0000000000..005c2f127d --- /dev/null +++ b/packages/preview/primaviz/0.12.0/data/codebase.json @@ -0,0 +1,101 @@ +{ + "title": "Open Source Project Stats", + + "subsystems": { + "labels": ["net", "fs", "drivers", "mm", "arch", "kernel"], + "values": [4820, 3150, 8930, 2710, 2340, 1890] + }, + + "patches": { + "labels": ["drivers", "net", "fs", "arch", "sound", "crypto"], + "values": [312, 187, 145, 98, 67, 42] + }, + + "languages": { + "labels": ["Rust", "C", "Python", "Go", "Other"], + "values": [35, 28, 18, 12, 7] + }, + + "contributors": { + "labels": ["Core Team", "Regular", "Occasional", "Drive-by", "Bots"], + "values": [120, 95, 55, 25, 5] + }, + + "modules": { + "name": "src", + "children": [ + {"name": "charts", "value": 40, "children": [ + {"name": "bar", "value": 15}, + {"name": "line", "value": 12}, + {"name": "misc", "value": 13} + ]}, + {"name": "primitives", "value": 35, "children": [ + {"name": "axes", "value": 20}, + {"name": "legend", "value": 15} + ]}, + {"name": "util", "value": 25} + ] + }, + + "dependencies": { + "labels": ["charts", "primitives", "theme", "util", "validate"], + "matrix": [ + [0, 25, 15, 10, 5], + [20, 0, 30, 8, 12], + [5, 10, 0, 15, 3], + [8, 5, 12, 0, 10], + [15, 8, 5, 10, 0] + ] + }, + + "rankings": { + "labels": ["v0.1", "v0.2", "v0.3", "v0.4", "v0.5"], + "series": [ + {"name": "Alice", "values": [1, 2, 1, 3, 2]}, + {"name": "Bob", "values": [3, 1, 2, 1, 1]}, + {"name": "Carol", "values": [2, 3, 3, 2, 3]} + ] + }, + + "health": { + "labels": ["net", "fs", "drivers", "mm", "arch"], + "values": [85, 72, 95, 60, 78] + }, + + "correlation": { + "labels": ["net", "fs", "mm", "drv", "arch"], + "values": [ + [1.0, 0.7, 0.3, 0.5, 0.2], + [0.7, 1.0, 0.4, 0.6, 0.3], + [0.3, 0.4, 1.0, 0.2, 0.8], + [0.5, 0.6, 0.2, 1.0, 0.4], + [0.2, 0.3, 0.8, 0.4, 1.0] + ] + }, + + "commits-by-lang": { + "labels": ["Jan", "Feb", "Mar", "Apr", "May", "Jun"], + "series": [ + {"name": "Rust", "values": [120, 145, 160, 180, 210, 195]}, + {"name": "C", "values": [95, 88, 82, 75, 70, 68]}, + {"name": "Python", "values": [60, 72, 85, 90, 95, 110]} + ] + }, + + "commit-calendar": { + "dates": [ + "2024-03-01", "2024-03-02", "2024-03-03", "2024-03-04", "2024-03-05", + "2024-03-06", "2024-03-07", "2024-03-08", "2024-03-09", "2024-03-10", + "2024-03-11", "2024-03-12", "2024-03-13", "2024-03-14", "2024-03-15", + "2024-03-16", "2024-03-17", "2024-03-18", "2024-03-19", "2024-03-20", + "2024-03-21", "2024-03-22", "2024-03-23", "2024-03-24", "2024-03-25", + "2024-03-26", "2024-03-27", "2024-03-28" + ], + "values": [8, 12, 3, 15, 18, 5, 1, 14, 22, 9, 0, 11, 7, 2, 16, 20, 13, 6, 19, 4, 0, 17, 10, 8, 21, 14, 3, 11] + }, + + "pr-merge-time": { + "labels": ["primaviz", "typst-core", "docs-site", "ci-runner", "sdk-go", "web-ui"], + "values": [4.2, 8.1, 2.5, 6.3, 5.8, 3.1] + } +} diff --git a/packages/preview/primaviz/0.12.0/data/league.json b/packages/preview/primaviz/0.12.0/data/league.json new file mode 100644 index 0000000000..e7f2e082bf --- /dev/null +++ b/packages/preview/primaviz/0.12.0/data/league.json @@ -0,0 +1,180 @@ +{ + "title": "Premier League Season 2024-25", + + "standings": { + "labels": ["Arsenal", "Liverpool", "Man City", "Chelsea", "Spurs", "Newcastle"], + "values": [72, 68, 65, 58, 52, 48] + }, + + "team-stats": { + "labels": ["Arsenal", "Liverpool", "Man City", "Chelsea", "Spurs"], + "series": [ + {"name": "Goals", "values": [68, 72, 65, 52, 48]}, + {"name": "Assists", "values": [55, 60, 58, 42, 38]}, + {"name": "Cards", "values": [48, 52, 40, 62, 58]} + ] + }, + + "player-stats": { + "labels": ["Pace", "Shooting", "Passing", "Dribbling", "Defense", "Physical"], + "series": [ + {"name": "Striker", "values": [88, 92, 75, 85, 35, 78]}, + {"name": "Midfielder", "values": [72, 68, 90, 82, 70, 75]} + ] + }, + + "team-scatter": { + "x": [68, 72, 65, 52, 48, 55], + "y": [28, 32, 30, 45, 50, 42], + "labels": ["Arsenal", "Liverpool", "Man City", "Chelsea", "Spurs", "Newcastle"] + }, + + "home-away": { + "series": [ + {"name": "Home", "points": [[38, 12], [40, 14], [35, 16], [28, 22], [25, 25]]}, + {"name": "Away", "points": [[30, 16], [32, 18], [30, 14], [24, 23], [23, 25]]} + ] + }, + + "team-bubble": { + "x": [68, 72, 65, 52, 48], + "y": [58, 55, 62, 48, 45], + "size": [850, 720, 950, 580, 420], + "labels": ["Arsenal", "Liverpool", "Man City", "Chelsea", "Spurs"] + }, + + "weekly-goals": { + "labels": ["GW1", "GW2", "GW3", "GW4", "GW5", "GW6", "GW7", "GW8", "GW9", "GW10"], + "series": [ + {"name": "Arsenal", "values": [2, 1, 3, 0, 2, 4, 1, 2, 3, 1]}, + {"name": "Liverpool", "values": [1, 3, 2, 2, 1, 0, 3, 2, 1, 4]}, + {"name": "Man City", "values": [3, 2, 1, 2, 3, 1, 2, 0, 4, 2]} + ] + }, + + "season-rankings": { + "labels": ["GW5", "GW10", "GW15", "GW20", "GW25"], + "series": [ + {"name": "Arsenal", "values": [2, 1, 1, 1, 1]}, + {"name": "Liverpool", "values": [1, 2, 2, 3, 2]}, + {"name": "Man City", "values": [3, 3, 3, 2, 3]} + ] + }, + + "home-vs-away-pct": { + "labels": ["Arsenal", "Liverpool", "Man City", "Chelsea", "Spurs"], + "left-values": [30, 32, 28, 35, 38], + "right-values": [70, 68, 72, 65, 62] + }, + + "season-slope": { + "labels": ["Arsenal", "Liverpool", "Man City", "Chelsea"], + "start-values": [3, 1, 2, 4], + "end-values": [1, 2, 3, 4] + }, + + "head-to-head": { + "labels": ["Arsenal", "Liverpool", "Man City", "Chelsea", "Spurs"], + "values": [ + [1.0, 0.6, 0.4, 0.8, 0.9], + [0.4, 1.0, 0.5, 0.7, 0.8], + [0.6, 0.5, 1.0, 0.6, 0.7], + [0.2, 0.3, 0.4, 1.0, 0.5], + [0.1, 0.2, 0.3, 0.5, 1.0] + ] + }, + + "goals-per-match": [2, 3, 3, 4, 4, 4, 5, 5, 5, 5, 6, 6, 6, 6, 6, 7, 7, 7, 7, 8, 8, 8, 9, 9, 10, 10, 11, 12, 14, 15, 18, 22, 25, 30, 35, 42, 55, 70, 95], + + "minutes-played": { + "labels": ["Forwards", "Midfield", "Defense", "Keepers"], + "boxes": [ + {"min": 15, "q1": 45, "median": 68, "q3": 82, "max": 90}, + {"min": 20, "q1": 55, "median": 72, "q3": 85, "max": 90}, + {"min": 30, "q1": 60, "median": 78, "q3": 88, "max": 90}, + {"min": 45, "q1": 75, "median": 85, "q3": 90, "max": 90} + ] + }, + + "ratings": { + "labels": ["Forwards", "Midfield", "Defense"], + "datasets": [ + [5, 8, 12, 15, 18, 22, 25, 28, 30, 32, 35, 38, 40, 42, 45, 48, 50, 52, 55, 58], + [10, 15, 20, 22, 25, 28, 30, 32, 35, 38, 40, 42, 45, 48, 50, 55, 58, 60, 62, 65], + [15, 18, 22, 25, 28, 30, 32, 35, 38, 40, 42, 45, 48, 50, 52, 55, 58, 60, 65, 70] + ] + }, + + "season-targets": [ + {"name": "Wins", "value": 24, "max": 38}, + {"name": "Goals", "value": 68, "max": 80}, + {"name": "Clean Sheets", "value": 12, "max": 20} + ], + + "points-target": {"actual": 72, "target": 70, "ranges": [40, 60, 80]}, + + "match-calendar": { + "dates": [ + "2024-08-17", "2024-08-24", "2024-08-31", "2024-09-14", "2024-09-21", + "2024-09-28", "2024-10-05", "2024-10-19", "2024-10-26", "2024-11-02", + "2024-11-09", "2024-11-23", "2024-11-30", "2024-12-04", "2024-12-07", + "2024-12-14", "2024-12-21", "2024-12-26", "2024-12-30", "2025-01-04", + "2025-01-15", "2025-01-18", "2025-01-25", "2025-02-01", "2025-02-15", + "2025-02-22", "2025-03-08", "2025-03-15" + ], + "values": [22, 18, 25, 15, 28, 20, 31, 24, 19, 27, 16, 30, 22, 35, 21, 26, 18, 33, 29, 14, 24, 28, 20, 32, 17, 25, 23, 30] + }, + + "season-timeline": { + "events": [ + {"date": "Aug 2024", "title": "Opening Day", "description": "Season kicks off"}, + {"date": "Sep 2024", "title": "Transfer Deadline", "description": "Summer window closes"}, + {"date": "Dec 2024", "title": "Boxing Day", "description": "Traditional fixture round"}, + {"date": "Jan 2025", "title": "January Window", "description": "Winter transfers open"}, + {"date": "May 2025", "title": "Final Day", "description": "Title decided"} + ] + }, + + "transfers": { + "nodes": ["Arsenal", "Liverpool", "Man City", "Chelsea", "Spurs", "Foreign"], + "flows": [ + {"from": 5, "to": 0, "value": 65}, + {"from": 5, "to": 1, "value": 45}, + {"from": 5, "to": 2, "value": 80}, + {"from": 5, "to": 3, "value": 110}, + {"from": 1, "to": 3, "value": 40}, + {"from": 0, "to": 5, "value": 25}, + {"from": 3, "to": 4, "value": 30}, + {"from": 2, "to": 5, "value": 35} + ] + }, + + "squad-value": { + "labels": ["Arsenal", "Man City", "Liverpool", "Chelsea", "Spurs", "Newcastle"], + "values": [850, 950, 720, 580, 420, 380] + }, + + "squad-tree": { + "name": "League", + "children": [ + {"name": "Arsenal", "value": 850, "children": [ + {"name": "GK", "value": 80}, + {"name": "DEF", "value": 250}, + {"name": "MID", "value": 300}, + {"name": "FWD", "value": 220} + ]}, + {"name": "Man City", "value": 950, "children": [ + {"name": "GK", "value": 90}, + {"name": "DEF", "value": 280}, + {"name": "MID", "value": 320}, + {"name": "FWD", "value": 260} + ]}, + {"name": "Chelsea", "value": 580, "children": [ + {"name": "GK", "value": 50}, + {"name": "DEF", "value": 160}, + {"name": "MID", "value": 210}, + {"name": "FWD", "value": 160} + ]} + ] + } +} diff --git a/packages/preview/primaviz/0.12.0/data/rpg.json b/packages/preview/primaviz/0.12.0/data/rpg.json new file mode 100644 index 0000000000..d2215b980f --- /dev/null +++ b/packages/preview/primaviz/0.12.0/data/rpg.json @@ -0,0 +1,121 @@ +{ + "title": "The Wayward Companions — Campaign Tracker", + + "characters": [ + {"name": "Thorin", "class": "Fighter", "kills": 47, "hp": 76, "ac": 18, "gold": 450, "level": 8}, + {"name": "Elara", "class": "Wizard", "kills": 31, "hp": 42, "ac": 13, "gold": 820, "level": 8}, + {"name": "Bramble", "class": "Rogue", "kills": 38, "hp": 54, "ac": 16, "gold": 1250, "level": 8}, + {"name": "Adelina", "class": "Cleric", "kills": 22, "hp": 62, "ac": 18, "gold": 340, "level": 8}, + {"name": "Zephyr", "class": "Sorcerer", "kills": 29, "hp": 45, "ac": 14, "gold": 680, "level": 7}, + {"name": "Grok", "class": "Barbarian", "kills": 68, "hp": 95, "ac": 15, "gold": 120, "level": 8} + ], + + "loot-breakdown": { + "labels": ["Gold", "Weapons", "Potions", "Scrolls", "Gems", "Misc"], + "values": [3660, 1800, 950, 620, 480, 290] + }, + + "party-gold": { + "labels": ["S1", "S2", "S3", "S4", "S5", "S6", "S7", "S8", "S9", "S10", "S11", "S12"], + "values": [500, 1200, 800, 2100, 3500, 2800, 4200, 3900, 5100, 4800, 6200, 3660] + }, + + "damage-dealt": { + "labels": ["Grok", "Thorin", "Elara", "Bramble", "Zephyr", "Adelina"], + "values": [436, 353, 318, 260, 320, 138] + }, + + "dungeon-loot": { + "labels": ["Start", "+Chest", "+Boss", "-Trap", "-Shop", "End"], + "values": [200, 450, 800, -120, -280, 1050] + }, + + "skill-usage": { + "rows": ["Thorin", "Elara", "Bramble", "Adelina", "Zephyr", "Grok"], + "cols": ["Attack", "Defense", "Magic", "Stealth", "Social"], + "values": [ + [95, 85, 10, 15, 20], + [15, 30, 95, 20, 45], + [60, 40, 15, 95, 70], + [45, 70, 80, 10, 85], + [35, 25, 90, 15, 75], + [98, 60, 5, 10, 15] + ] + }, + + "stat-gains": { + "labels": ["STR", "DEX", "CON", "INT", "WIS", "CHA"], + "start-values": [14, 10, 12, 8, 10, 7], + "end-values": [20, 14, 18, 10, 13, 8] + }, + + "quest-progress": { + "labels": ["Main Quest", "Side Quests", "Exploration", "Combat Rating", "Roleplay"], + "values": [85, 62, 45, 92, 78] + }, + + "class-tree": { + "name": "Party", + "children": [ + {"name": "Martial", "value": 50, "children": [ + {"name": "Fighter", "value": 20, "children": [ + {"name": "Champion", "value": 12}, + {"name": "Battle Master", "value": 8} + ]}, + {"name": "Barbarian", "value": 15, "children": [ + {"name": "Berserker", "value": 10}, + {"name": "Totem", "value": 5} + ]}, + {"name": "Rogue", "value": 15, "children": [ + {"name": "Thief", "value": 8}, + {"name": "Assassin", "value": 7} + ]} + ]}, + {"name": "Caster", "value": 35, "children": [ + {"name": "Wizard", "value": 15, "children": [ + {"name": "Evocation", "value": 9}, + {"name": "Divination", "value": 6} + ]}, + {"name": "Sorcerer", "value": 10}, + {"name": "Cleric", "value": 10} + ]} + ] + }, + + "interaction-matrix": { + "labels": ["Thorin", "Elara", "Bramble", "Adelina", "Zephyr", "Grok"], + "matrix": [ + [0, 12, 8, 15, 5, 20], + [12, 0, 10, 18, 22, 3], + [8, 10, 0, 6, 14, 5], + [15, 18, 6, 0, 12, 8], + [5, 22, 14, 12, 0, 10], + [20, 3, 5, 8, 10, 0] + ] + }, + + "campaign-arcs": { + "tasks": [ + {"name": "Intro", "start": 0, "end": 2, "group": "Act I"}, + {"name": "Village", "start": 1, "end": 4, "group": "Act I"}, + {"name": "Dungeon", "start": 3, "end": 7, "group": "Act II"}, + {"name": "Betrayal", "start": 6, "end": 9, "group": "Act II"}, + {"name": "Boss", "start": 8, "end": 11, "group": "Finale"}, + {"name": "Epilogue", "start": 11, "end": 13, "group": "Finale"} + ], + "time-labels": ["S1", "S2", "S3", "S4", "S5", "S6", "S7", "S8", "S9", "S10", "S11", "S12", "S13"] + }, + + "quest-milestones": { + "events": [ + {"date": "Session 1", "title": "The Gathering", "description": "Party formed at tavern"}, + {"date": "Session 4", "title": "First Dungeon", "description": "Cleared the Goblin Warren"}, + {"date": "Session 8", "title": "The Betrayal", "description": "NPC ally revealed as villain"}, + {"date": "Session 12", "title": "Final Battle", "description": "Defeated the Lich King"} + ] + }, + + "xp-progress": 88, + "quest-completion": 75, + "party-health": 82 +} diff --git a/packages/preview/primaviz/0.12.0/data/sales.json b/packages/preview/primaviz/0.12.0/data/sales.json new file mode 100644 index 0000000000..0544dfae1a --- /dev/null +++ b/packages/preview/primaviz/0.12.0/data/sales.json @@ -0,0 +1,174 @@ +{ + "title": "SaaS Startup Metrics", + + "quarterly": { + "labels": ["Q1", "Q2", "Q3", "Q4"], + "series": [ + {"name": "Enterprise", "values": [120, 150, 180, 210]}, + {"name": "Growth", "values": [80, 95, 110, 140]}, + {"name": "Starter", "values": [40, 45, 50, 55]} + ] + }, + + "channels": { + "labels": ["Q1", "Q2", "Q3", "Q4"], + "groups": [ + {"name": "Enterprise", "segments": [ + {"name": "Online", "values": [40, 50, 60, 70]}, + {"name": "Retail", "values": [30, 35, 40, 45]} + ]}, + {"name": "Growth", "segments": [ + {"name": "Online", "values": [55, 65, 75, 90]}, + {"name": "Retail", "values": [20, 25, 28, 35]} + ]} + ] + }, + + "monthly": { + "labels": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul"], + "values": [120, 135, 142, 158, 165, 182, 195] + }, + + "monthly-series": { + "labels": ["Jan", "Feb", "Mar", "Apr", "May", "Jun"], + "series": [ + {"name": "Revenue ($K)", "values": [120, 150, 180, 165, 210, 240]}, + {"name": "Costs ($K)", "values": [80, 85, 95, 90, 100, 110]}, + {"name": "Profit ($K)", "values": [40, 65, 85, 75, 110, 130]} + ] + }, + + "dual-axis": { + "labels": ["Jan", "Feb", "Mar", "Apr", "May", "Jun"], + "left": {"name": "Revenue ($K)", "values": [120, 150, 180, 165, 210, 240]}, + "right": {"name": "Users (K)", "values": [1.2, 1.8, 2.1, 2.5, 3.0, 3.8]} + }, + + "funnel": { + "labels": ["Leads", "Qualified", "Demo", "Proposal", "Closed"], + "values": [1200, 800, 450, 280, 120] + }, + + "waterfall": { + "labels": ["Start", "+Sales", "+Services", "-COGS", "-OpEx", "Total"], + "values": [1200, 350, 180, -280, -150, 1300] + }, + + "satisfaction": { + "labels": ["Enterprise", "Growth", "Starter", "Free Tier"], + "left-values": [15, 30, 45, 60], + "right-values": [85, 70, 55, 40] + }, + + "targets": { + "labels": ["Sales", "Eng", "Marketing", "Support", "Design"], + "values": [87, 72, 65, 91, 58] + }, + + "revenue-target": {"actual": 275, "target": 250, "ranges": [150, 225, 300]}, + + "conversion-rate": 78, + "uptime": 94, + "nps": 67, + + "kpis": [ + {"value": 12847, "label": "Users", "delta": 12.3, "trend": [45, 52, 48, 61, 58, 72]}, + {"value": 94.2, "label": "Uptime", "delta": 0.5, "suffix": "%", "trend": [91, 93, 92, 94, 93, 94]}, + {"value": 342, "label": "Issues", "delta": -8.1, "trend": [380, 365, 370, 355, 350, 342]} + ], + + "sparklines": { + "networking": [45, 52, 48, 61, 58, 72, 68], + "memory": [32, 28, 35, 31, 38, 42, 40], + "storage": [22, 25, 19, 28, 24, 30, 27] + }, + + "expenses": { + "labels": ["Salaries", "Infra", "Marketing", "Office", "R&D", "Legal"], + "values": [1200, 800, 400, 300, 500, 250] + }, + + "activity": { + "rows": ["Sales", "Eng", "Marketing", "Support"], + "cols": ["Mon", "Tue", "Wed", "Thu", "Fri"], + "values": [ + [82, 95, 78, 88, 65], + [45, 52, 68, 71, 38], + [33, 41, 55, 48, 29], + [91, 87, 93, 85, 72] + ] + }, + + "daily-deals": { + "dates": [ + "2024-03-01", "2024-03-02", "2024-03-03", "2024-03-04", "2024-03-05", + "2024-03-06", "2024-03-07", "2024-03-08", "2024-03-09", "2024-03-10", + "2024-03-11", "2024-03-12", "2024-03-13", "2024-03-14", "2024-03-15", + "2024-03-16", "2024-03-17", "2024-03-18", "2024-03-19", "2024-03-20", + "2024-03-21", "2024-03-22", "2024-03-23", "2024-03-24", "2024-03-25", + "2024-03-26", "2024-03-27", "2024-03-28" + ], + "values": [12, 8, 3, 15, 22, 18, 5, 9, 14, 2, 20, 25, 11, 7, 16, 4, 1, 19, 23, 13, 10, 6, 17, 8, 21, 15, 12, 9] + }, + + "schedule": { + "tasks": [ + {"name": "Research", "start": 0, "end": 3, "group": "Plan"}, + {"name": "Design", "start": 2, "end": 5, "group": "Plan"}, + {"name": "Backend", "start": 4, "end": 9, "group": "Build"}, + {"name": "Frontend", "start": 5, "end": 10, "group": "Build"}, + {"name": "Testing", "start": 8, "end": 11, "group": "QA"}, + {"name": "Launch", "start": 11, "end": 13, "group": "Ship"} + ], + "time-labels": ["W1", "W2", "W3", "W4", "W5", "W6", "W7", "W8", "W9", "W10", "W11", "W12", "W13"] + }, + + "milestones": { + "events": [ + {"date": "Jan 2024", "title": "Kickoff", "description": "Project started"}, + {"date": "Mar 2024", "title": "Alpha", "description": "Internal release"}, + {"date": "Jun 2024", "title": "Beta", "description": "Public beta"}, + {"date": "Sep 2024", "title": "v1.0", "description": "Stable release"} + ] + }, + + "org": { + "name": "Company", + "children": [ + {"name": "Eng", "value": 40, "children": [ + {"name": "Backend", "value": 25}, + {"name": "Frontend", "value": 15} + ]}, + {"name": "Sales", "value": 35, "children": [ + {"name": "Enterprise", "value": 20}, + {"name": "SMB", "value": 15} + ]}, + {"name": "Ops", "value": 25} + ] + }, + + "slope": { + "labels": ["Enterprise", "Growth", "Starter", "Free Tier"], + "start-values": [85, 70, 60, 45], + "end-values": [65, 90, 55, 80] + }, + + "dumbbell": { + "labels": ["Revenue", "Users", "NPS", "Uptime", "Latency"], + "start-values": [45, 60, 72, 88, 35], + "end-values": [78, 85, 68, 95, 22] + }, + + "budget-flow": { + "nodes": ["Revenue", "Salaries", "R&D", "Marketing", "Infra", "Savings", "Growth Fund", "Reserve"], + "flows": [ + {"from": 0, "to": 1, "value": 5000}, + {"from": 0, "to": 2, "value": 2000}, + {"from": 0, "to": 3, "value": 1500}, + {"from": 0, "to": 4, "value": 800}, + {"from": 2, "to": 5, "value": 1200}, + {"from": 2, "to": 6, "value": 800}, + {"from": 4, "to": 7, "value": 500} + ] + } +} diff --git a/packages/preview/primaviz/0.12.0/data/words.json b/packages/preview/primaviz/0.12.0/data/words.json new file mode 100644 index 0000000000..8b386f2e5f --- /dev/null +++ b/packages/preview/primaviz/0.12.0/data/words.json @@ -0,0 +1,56 @@ +{ + "title": "Data Visualization Vocabulary", + + "words": [ + {"text": "Distribution", "weight": 10, "length": 12, "category": "concept"}, + {"text": "Histogram", "weight": 9, "length": 9, "category": "chart"}, + {"text": "Correlation", "weight": 9, "length": 11, "category": "concept"}, + {"text": "Treemap", "weight": 8, "length": 7, "category": "chart"}, + {"text": "Scatter", "weight": 8, "length": 7, "category": "chart"}, + {"text": "Outlier", "weight": 8, "length": 7, "category": "concept"}, + {"text": "Dashboard", "weight": 8, "length": 9, "category": "format"}, + {"text": "Heatmap", "weight": 7, "length": 7, "category": "chart"}, + {"text": "Radar", "weight": 7, "length": 5, "category": "chart"}, + {"text": "Funnel", "weight": 7, "length": 6, "category": "chart"}, + {"text": "Sankey", "weight": 7, "length": 6, "category": "chart"}, + {"text": "Aggregation", "weight": 7, "length": 11, "category": "technique"}, + {"text": "Trend", "weight": 7, "length": 5, "category": "concept"}, + {"text": "Axis", "weight": 6, "length": 4, "category": "element"}, + {"text": "Legend", "weight": 6, "length": 6, "category": "element"}, + {"text": "Tooltip", "weight": 6, "length": 7, "category": "element"}, + {"text": "Palette", "weight": 6, "length": 7, "category": "element"}, + {"text": "Binning", "weight": 6, "length": 7, "category": "technique"}, + {"text": "Normalize", "weight": 6, "length": 9, "category": "technique"}, + {"text": "Sparkline", "weight": 5, "length": 9, "category": "chart"}, + {"text": "Waterfall", "weight": 5, "length": 9, "category": "chart"}, + {"text": "Violin", "weight": 5, "length": 6, "category": "chart"}, + {"text": "Sunburst", "weight": 5, "length": 8, "category": "chart"}, + {"text": "Chord", "weight": 5, "length": 5, "category": "chart"}, + {"text": "Annotation", "weight": 5, "length": 10, "category": "element"}, + {"text": "Faceting", "weight": 5, "length": 8, "category": "technique"}, + {"text": "Smoothing", "weight": 5, "length": 9, "category": "technique"}, + {"text": "Grid", "weight": 4, "length": 4, "category": "element"}, + {"text": "Donut", "weight": 4, "length": 5, "category": "chart"}, + {"text": "Bubble", "weight": 4, "length": 6, "category": "chart"}, + {"text": "Lollipop", "weight": 4, "length": 8, "category": "chart"}, + {"text": "Diverging", "weight": 4, "length": 9, "category": "chart"}, + {"text": "Dumbbell", "weight": 3, "length": 8, "category": "chart"}, + {"text": "Waffle", "weight": 3, "length": 6, "category": "chart"}, + {"text": "Parliament", "weight": 3, "length": 10, "category": "chart"}, + {"text": "Slope", "weight": 3, "length": 5, "category": "chart"}, + {"text": "Bump", "weight": 3, "length": 4, "category": "chart"}, + {"text": "Radial", "weight": 3, "length": 6, "category": "chart"}, + {"text": "Metric", "weight": 3, "length": 6, "category": "element"}, + {"text": "Encoding", "weight": 3, "length": 8, "category": "concept"}, + {"text": "Percentile", "weight": 2, "length": 10, "category": "concept"}, + {"text": "Quartile", "weight": 2, "length": 8, "category": "concept"}, + {"text": "Interpolation","weight": 2, "length": 13, "category": "technique"}, + {"text": "SVG", "weight": 1, "length": 3, "category": "format"}, + {"text": "PDF", "weight": 1, "length": 3, "category": "format"} + ], + + "category-counts": { + "labels": ["chart", "concept", "element", "technique", "format"], + "values": [20, 8, 7, 6, 3] + } +} diff --git a/packages/preview/primaviz/0.12.0/examples/demo-data.typ b/packages/preview/primaviz/0.12.0/examples/demo-data.typ new file mode 100644 index 0000000000..df55a3d9d2 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/examples/demo-data.typ @@ -0,0 +1,14 @@ +// demo-data.typ — Thin loader for shared JSON datasets +// +// Five themed datasets cover every chart data shape: +// sales.* — SaaS company metrics (bars, lines, funnels, gauges, flows) +// codebase.* — Open source project stats (lollipops, waffle, chord, word cloud) +// league.* — Soccer league data (radar, scatter, distributions, rankings) +// rpg.* — D&D campaign tracker (characters, combat, quests, loot) +// words.* — Data visualization vocabulary (word cloud, categories) + +#let sales = json("../data/sales.json") +#let codebase = json("../data/codebase.json") +#let league = json("../data/league.json") +#let rpg = json("../data/rpg.json") +#let words = json("../data/words.json") diff --git a/packages/preview/primaviz/0.12.0/examples/demo.typ b/packages/preview/primaviz/0.12.0/examples/demo.typ new file mode 100644 index 0000000000..b3731f3814 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/examples/demo.typ @@ -0,0 +1,330 @@ +// Chart Demo — Getting started walkthrough using shared datasets +#import "@preview/primaviz:0.12.0": * +#import "demo-data.typ": sales, codebase, league, rpg, words + +#set page(margin: 1cm) +#set text(size: 10pt) + += Typst Charting Library Demo + +== Bar Charts + +=== Vertical Bar Chart +#bar-chart( + (labels: rpg.characters.map(c => c.name), values: rpg.characters.map(c => c.kills)), + width: 400pt, height: 180pt, + title: "Enemy Kills by Character", y-label: "Kills", +) + +#v(20pt) + +=== Horizontal Bar Chart +#horizontal-bar-chart(codebase.patches, + width: 400pt, height: 200pt, + title: "Patches by Subsystem", x-label: "Patches", +) + +#pagebreak() + +=== Grouped Bar Chart +#grouped-bar-chart(league.team-stats, + width: 400pt, height: 200pt, + title: "Team Stats Comparison", y-label: "Count", +) + +#v(20pt) + +=== Stacked Bar Chart +#stacked-bar-chart(sales.quarterly, + width: 350pt, height: 200pt, + title: "Quarterly Sales by Product", +) + +#pagebreak() + +== Line Charts + +=== Single Line Chart +#line-chart(rpg.party-gold, + width: 450pt, height: 200pt, + title: "Party Gold by Session", show-points: true, +) + +#v(20pt) + +=== Multi-Line Chart +#multi-line-chart(league.weekly-goals, + width: 450pt, height: 200pt, + title: "Goals per Gameweek", +) + +#pagebreak() + +== Area Charts + +=== Single Area Chart +#area-chart(sales.monthly, + width: 450pt, height: 180pt, + title: "Monthly Revenue", fill-opacity: 50%, +) + +#v(20pt) + +=== Stacked Area Chart +#stacked-area-chart(codebase.commits-by-lang, + width: 450pt, height: 200pt, + title: "Commits by Language Over Time", +) + +#pagebreak() + +== Pie Charts + +#grid( + columns: (1fr, 1fr), + column-gutter: 20pt, + + [ + === Pie Chart + #pie-chart(codebase.languages, + size: 150pt, title: "Language Mix", + ) + ], + + [ + === Donut Chart + #pie-chart(rpg.loot-breakdown, + size: 150pt, title: "Loot Distribution", + donut: true, donut-ratio: 0.5, + ) + ] +) + +#pagebreak() + +== Radar Charts + +=== Multi-Series Radar +#radar-chart(league.player-stats, + size: 250pt, title: "Player Comparison — Striker vs Midfielder", + fill-opacity: 25%, +) + +#pagebreak() + +== Scatter Plots + +=== Simple Scatter Plot +#scatter-plot( + (x: rpg.characters.map(c => c.hp), y: rpg.characters.map(c => c.kills)), + width: 350pt, height: 250pt, + title: "HP vs Kills", x-label: "Hit Points", y-label: "Kill Count", +) + +#v(15pt) + +=== Multi-Series Scatter Plot +#multi-scatter-plot(league.home-away, + width: 350pt, height: 250pt, + title: "Home vs Away Performance", + x-label: "Goals Scored", y-label: "Goals Conceded", +) + +#pagebreak() + +== Bubble Chart + +#bubble-chart(league.team-bubble, + width: 400pt, height: 300pt, + title: "Goals vs Possession (bubble = market value)", + x-label: "Goals", y-label: "Possession %", + min-radius: 8pt, max-radius: 35pt, + show-labels: true, labels: league.team-bubble.labels, +) + +#pagebreak() + +== Gauges & Progress Indicators + +=== Gauge Charts +#grid( + columns: (1fr, 1fr, 1fr), + column-gutter: 10pt, + gauge-chart(sales.conversion-rate, size: 120pt, title: "Conversion", label: "rate"), + gauge-chart(sales.uptime, size: 120pt, title: "Uptime", label: "%"), + gauge-chart(sales.nps, size: 120pt, title: "NPS", label: "score"), +) + +#v(20pt) + +=== Progress Bars +#grid( + columns: (1fr, 1fr), + column-gutter: 30pt, + + [ + ==== Horizontal Progress + #progress-bar(rpg.quest-completion, width: 200pt, title: "Main Quest") + #v(10pt) + #progress-bar(rpg.xp-progress, width: 200pt, title: "XP Progress", color: rgb("#f28e2b")) + #v(10pt) + #progress-bar(rpg.party-health, width: 200pt, title: "Party Health", color: rgb("#e15759")) + ], + + [ + ==== Circular Progress + #grid( + columns: (1fr, 1fr, 1fr), + circular-progress(rpg.quest-completion, size: 70pt, title: "Quest"), + circular-progress(rpg.xp-progress, size: 70pt, title: "XP", color: rgb("#f28e2b")), + circular-progress(rpg.party-health, size: 70pt, title: "Health", color: rgb("#59a14f")), + ) + ] +) + +#v(20pt) + +=== Multiple Progress Bars +#progress-bars(rpg.quest-progress, + width: 350pt, title: "Campaign Progress", +) + +#pagebreak() + +== Heatmaps + +=== Skill Usage Heatmap +#heatmap(rpg.skill-usage, + cell-size: 35pt, title: "Skill Usage by Character", palette: "viridis", +) + +#v(30pt) + +=== Correlation Matrix +#correlation-matrix(codebase.correlation, + cell-size: 40pt, title: "Module Coupling", +) + +#pagebreak() + +=== Calendar Heatmap +#calendar-heatmap(league.match-calendar, + cell-size: 12pt, title: "Match-Day Total Goals", palette: "heat", +) + +#pagebreak() + +== Sparklines + +#v(10pt) + +#table( + columns: (auto, auto, auto, auto), + align: (left, center, center, center), + [*Metric*], [*Sparkline*], [*Sparkbar*], [*Sparkdot*], + [Networking], + [#sparkline(sales.sparklines.networking, color: rgb("#59a14f"))], + [#sparkbar(sales.sparklines.networking, color: rgb("#59a14f"))], + [#sparkdot(sales.sparklines.networking, color: rgb("#59a14f"))], + [Memory], + [#sparkline(sales.sparklines.memory, color: rgb("#4e79a7"))], + [#sparkbar(sales.sparklines.memory, color: rgb("#4e79a7"))], + [#sparkdot(sales.sparklines.memory, color: rgb("#4e79a7"))], + [Storage], + [#sparkline(sales.sparklines.storage, color: rgb("#e15759"))], + [#sparkbar(sales.sparklines.storage, color: rgb("#e15759"))], + [#sparkdot(sales.sparklines.storage, color: rgb("#e15759"))], +) + +#pagebreak() + +== Waterfall Chart + +#waterfall-chart(rpg.dungeon-loot, + width: 400pt, height: 220pt, + title: "Dungeon Loot — Profit & Loss", +) + +#v(30pt) + +== Funnel Chart + +#funnel-chart(sales.funnel, + width: 300pt, height: 250pt, + title: "Sales Pipeline", +) + +#pagebreak() + +== Box Plot + +#box-plot(league.minutes-played, + width: 350pt, height: 220pt, + title: "Minutes Played by Position", show-grid: true, +) + +#pagebreak() + +== Annotations + +#v(10pt) + +=== Line Chart with Target & Goal Zone +#line-chart( + sales.monthly, + width: 400pt, height: 200pt, + title: "Monthly Revenue with Annotations", show-points: true, + annotations: ( + (type: "h-band", from: 140, to: 180, label: "Target Zone", color: rgb("#59a14f")), + (type: "h-line", value: 160, label: "Target", color: rgb("#e15759"), dash: "dashed"), + ), +) + +#v(20pt) + +=== Bar Chart with Average Line +#bar-chart( + (labels: rpg.characters.map(c => c.name), values: rpg.characters.map(c => c.kills)), + width: 350pt, height: 200pt, + title: "Kills with Party Average", + annotations: ( + (type: "h-line", value: 39, label: "Avg", color: rgb("#4e79a7"), dash: "dashed"), + ), +) + +#pagebreak() + +== Theme Showcase + +=== Dark Theme +#bar-chart( + (labels: rpg.characters.map(c => c.name), values: rpg.characters.map(c => c.gold)), + width: 350pt, height: 180pt, + title: "Gold per Character", theme: themes.dark, +) + +#v(20pt) + +=== Minimal Theme +#line-chart(sales.monthly, + width: 350pt, height: 180pt, + title: "Monthly Revenue", show-points: true, theme: themes.minimal, +) + +#v(20pt) + +=== Print Theme (grayscale) +#grouped-bar-chart(league.team-stats, + width: 300pt, height: 160pt, + title: "Team Stats", theme: themes.print, +) + +#v(40pt) + +#align(center)[ + #text(size: 8pt, fill: gray)[ + Charts generated using Typst primitives (rect, circle, line, polygon, place) \ + 50+ chart types with theme system, validation, and annotations \ + No external dependencies required + ] +] diff --git a/packages/preview/primaviz/0.12.0/examples/demos/demo-annotations.typ b/packages/preview/primaviz/0.12.0/examples/demos/demo-annotations.typ new file mode 100644 index 0000000000..578727cd2a --- /dev/null +++ b/packages/preview/primaviz/0.12.0/examples/demos/demo-annotations.typ @@ -0,0 +1,90 @@ +// Annotations, error bars, and outliers: content/point/errorbar/rect overlays +// plus native errors: parameter and box-plot outliers: field. +#import "@preview/primaviz:0.12.0": * +#set page(margin: 0.5cm, paper: "a4") +#set text(size: 8pt) + +#let lt = themes.default +#let dk = themes.dark +#let W = 250pt +#let H = 280pt + +// --- Bar chart with native error bars + h-line target + content annotation --- +#let bar-data = ( + labels: ("Q1", "Q2", "Q3", "Q4"), + values: (120, 150, 165, 180), +) +#let bar-errors = (10, 12, 8, 15) // symmetric + +// --- Line chart with h-band confidence + point + content annotations --- +#let line-data = ( + labels: ("Jan", "Feb", "Mar", "Apr", "May", "Jun"), + values: (42, 55, 48, 72, 68, 85), +) + +// --- Box plot with outliers --- +#let box-data = ( + labels: ("Group A", "Group B", "Group C"), + boxes: ( + (min: 20, q1: 35, median: 50, q3: 65, max: 80, outliers: (95, 5)), + (min: 15, q1: 30, median: 45, q3: 60, max: 75, outliers: (92, 88)), + (min: 25, q1: 40, median: 52, q3: 68, max: 82, outliers: (2,)), + ), +) + +// --- Scatter with errorbar annotations + rect region + content label --- +#let scatter-data = ( + x: (1, 2, 3, 4, 5, 6), + y: (12, 18, 15, 22, 28, 25), +) + +#page-grid(cols: 2, rows: 2, col-gutter: 16pt, row-gutter: 24pt, ( + bar-chart(bar-data, + width: W, height: H, + title: "errors + annotations", + x-label: "Quarter", y-label: "Revenue (k$)", + errors: bar-errors, + show-values: false, + annotations: ( + (type: "h-line", value: 160, color: rgb("#c0392b"), dash: "dashed", label: "target"), + (type: "content", x: 2, y: 175, body: text(weight: "bold", fill: rgb("#c0392b"))[★ hit], anchor: "bottom"), + ), + theme: lt, + ), + line-chart(line-data, + width: W, height: H, + title: "h-band + content", + x-label: "Month", y-label: "Sales", + show-points: true, + annotations: ( + (type: "h-band", from: 60, to: 75, color: rgb("#2ecc71"), opacity: 18%, label: "goal zone"), + (type: "content", x: 5, y: 85, body: text(fill: rgb("#e74c3c"), weight: "bold")[peak], anchor: "bottom", dy: -6pt), + (type: "point", x: 5, y: 85, radius: 4pt, fill: rgb("#e74c3c")), + ), + theme: dk, + ), + box-plot(box-data, + width: W, height: H, + title: "outliers", + x-label: "Group", y-label: "Value", + show-grid: true, + theme: lt, + ), + scatter-plot(scatter-data, + width: W, height: H, + title: "errorbar + rect + point", + x-label: "X", y-label: "Y", + show-grid: true, + annotations: ( + (type: "rect", x1: 3.5, y1: 20, x2: 5.5, y2: 30, color: rgb("#3498db"), opacity: 15%, label: "region"), + (type: "errorbar", x: 1, low: 10, high: 14, color: rgb("#7f8c8d")), + (type: "errorbar", x: 2, low: 16, high: 20, color: rgb("#7f8c8d")), + (type: "errorbar", x: 3, low: 13, high: 17, color: rgb("#7f8c8d")), + (type: "errorbar", x: 4, low: 20, high: 24, color: rgb("#7f8c8d")), + (type: "errorbar", x: 5, low: 26, high: 30, color: rgb("#7f8c8d")), + (type: "errorbar", x: 6, low: 23, high: 27, color: rgb("#7f8c8d")), + (type: "content", x: 5, y: 28, body: text(size: 7pt, fill: rgb("#e74c3c"))[outlier], anchor: "left", dx: 6pt), + ), + theme: dk, + ), +)) diff --git a/packages/preview/primaviz/0.12.0/examples/demos/demo-area.typ b/packages/preview/primaviz/0.12.0/examples/demos/demo-area.typ new file mode 100644 index 0000000000..7d6649452b --- /dev/null +++ b/packages/preview/primaviz/0.12.0/examples/demos/demo-area.typ @@ -0,0 +1,25 @@ +// Area charts: single + stacked, light + dark +#import "@preview/primaviz:0.12.0": * +#import "../demo-data.typ": sales +#set page(margin: 0.5cm, paper: "a4") +#set text(size: 8pt) + +#let lt = themes.default +#let dk = themes.dark +#let W = 250pt +#let H = 280pt + +#page-grid(cols: 2, rows: 2, col-gutter: 16pt, row-gutter: 24pt, ( + area-chart(sales.monthly, + width: W, height: H, title: "area-chart (light)", fill-opacity: 40%, x-label: "Month", y-label: "Revenue ($K)", theme: lt, + ), + area-chart(sales.monthly, + width: W, height: H, title: "area-chart (dark)", fill-opacity: 40%, x-label: "Month", y-label: "Revenue ($K)", theme: dk, + ), + stacked-area-chart(sales.monthly-series, + width: W, height: H, title: "stacked-area-chart (light)", x-label: "Month", y-label: "Commits", theme: lt, + ), + stacked-area-chart(sales.monthly-series, + width: W, height: H, title: "stacked-area-chart (dark)", x-label: "Month", y-label: "Commits", theme: dk, + ), +)) diff --git a/packages/preview/primaviz/0.12.0/examples/demos/demo-bar-advanced.typ b/packages/preview/primaviz/0.12.0/examples/demos/demo-bar-advanced.typ new file mode 100644 index 0000000000..52ae6fdd63 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/examples/demos/demo-bar-advanced.typ @@ -0,0 +1,27 @@ +// Advanced bar charts: grouped-stacked + diverging, light + dark +#import "@preview/primaviz:0.12.0": * +#import "../demo-data.typ": sales +#set page(margin: 0.5cm, paper: "a4") +#set text(size: 8pt) + +#let lt = themes.default +#let dk = themes.dark +#let W = 250pt +#let H = 280pt + +#page-grid(cols: 2, rows: 2, col-gutter: 16pt, row-gutter: 24pt, ( + grouped-stacked-bar-chart(sales.channels, + width: W, height: H, title: "grouped-stacked (light)", x-label: "Quarter", y-label: "Revenue ($K)", theme: lt, + ), + grouped-stacked-bar-chart(sales.channels, + width: W, height: H, title: "grouped-stacked (dark)", x-label: "Quarter", y-label: "Revenue ($K)", theme: dk, + ), + diverging-bar-chart( + (..sales.satisfaction, left-label: "Detractors", right-label: "Promoters"), + width: W, height: H, title: "diverging-bar (light)", x-label: "Responses", theme: lt, + ), + diverging-bar-chart( + (..sales.satisfaction, left-label: "Detractors", right-label: "Promoters"), + width: W, height: H, title: "diverging-bar (dark)", x-label: "Responses", theme: dk, + ), +)) diff --git a/packages/preview/primaviz/0.12.0/examples/demos/demo-bar-multi.typ b/packages/preview/primaviz/0.12.0/examples/demos/demo-bar-multi.typ new file mode 100644 index 0000000000..818429e95d --- /dev/null +++ b/packages/preview/primaviz/0.12.0/examples/demos/demo-bar-multi.typ @@ -0,0 +1,25 @@ +// Multi-series bar charts: grouped + stacked, light + dark +#import "@preview/primaviz:0.12.0": * +#import "../demo-data.typ": sales +#set page(margin: 0.5cm, paper: "a4") +#set text(size: 8pt) + +#let lt = themes.default +#let dk = themes.dark +#let W = 250pt +#let H = 280pt + +#page-grid(cols: 2, rows: 2, col-gutter: 16pt, row-gutter: 24pt, ( + grouped-bar-chart(sales.quarterly, + width: W, height: H, title: "grouped-bar-chart (light)", x-label: "Quarter", y-label: "Count", theme: lt, + ), + grouped-bar-chart(sales.quarterly, + width: W, height: H, title: "grouped-bar-chart (dark)", x-label: "Quarter", y-label: "Count", theme: dk, + ), + stacked-bar-chart(sales.quarterly, + width: W, height: H, title: "stacked-bar-chart (light)", x-label: "Quarter", y-label: "Revenue", theme: lt, + ), + stacked-bar-chart(sales.quarterly, + width: W, height: H, title: "stacked-bar-chart (dark)", x-label: "Quarter", y-label: "Revenue", theme: dk, + ), +)) diff --git a/packages/preview/primaviz/0.12.0/examples/demos/demo-bar.typ b/packages/preview/primaviz/0.12.0/examples/demos/demo-bar.typ new file mode 100644 index 0000000000..e3b3e9dc98 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/examples/demos/demo-bar.typ @@ -0,0 +1,25 @@ +// Bar charts: vertical + horizontal, light + dark +#import "@preview/primaviz:0.12.0": * +#import "../demo-data.typ": codebase +#set page(margin: 0.5cm, paper: "a4") +#set text(size: 8pt) + +#let lt = themes.default +#let dk = themes.dark +#let W = 250pt +#let H = 280pt + +#page-grid(cols: 2, rows: 2, col-gutter: 16pt, row-gutter: 24pt, ( + bar-chart(codebase.subsystems, + width: W, height: H, title: "bar-chart (light)", x-label: "Subsystem", y-label: "LoC", theme: lt, + ), + bar-chart(codebase.subsystems, + width: W, height: H, title: "bar-chart (dark)", x-label: "Subsystem", y-label: "LoC", theme: dk, + ), + horizontal-bar-chart(codebase.patches, + width: W, height: H, title: "horizontal-bar-chart (light)", x-label: "Patches", theme: lt, + ), + horizontal-bar-chart(codebase.patches, + width: W, height: H, title: "horizontal-bar-chart (dark)", x-label: "Patches", theme: dk, + ), +)) diff --git a/packages/preview/primaviz/0.12.0/examples/demos/demo-bump.typ b/packages/preview/primaviz/0.12.0/examples/demos/demo-bump.typ new file mode 100644 index 0000000000..7955eec2b5 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/examples/demos/demo-bump.typ @@ -0,0 +1,25 @@ +// Bump & funnel: bump light + dark, funnel light + dark +#import "@preview/primaviz:0.12.0": * +#import "../demo-data.typ": codebase, sales +#set page(margin: 0.5cm, paper: "a4") +#set text(size: 8pt) + +#let lt = themes.default +#let dk = themes.dark +#let W = 250pt +#let H = 280pt + +#page-grid(cols: 2, rows: 2, col-gutter: 16pt, row-gutter: 24pt, ( + bump-chart(codebase.rankings, + width: W, height: H, title: "bump-chart (light)", dot-size: 4pt, theme: lt, + ), + bump-chart(codebase.rankings, + width: W, height: H, title: "bump-chart (dark)", dot-size: 4pt, theme: dk, + ), + funnel-chart(sales.funnel, + width: W, height: H, title: "funnel-chart (light)", theme: lt, + ), + funnel-chart(sales.funnel, + width: W, height: H, title: "funnel-chart (dark)", theme: dk, + ), +)) diff --git a/packages/preview/primaviz/0.12.0/examples/demos/demo-comparison.typ b/packages/preview/primaviz/0.12.0/examples/demos/demo-comparison.typ new file mode 100644 index 0000000000..109ef680d0 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/examples/demos/demo-comparison.typ @@ -0,0 +1,33 @@ +// Comparison: slope, dumbbell, lollipop, bullet +#import "@preview/primaviz:0.12.0": * +#import "../demo-data.typ": sales, codebase +#set page(margin: 0.5cm, paper: "a4") +#set text(size: 8pt) + +#let lt = themes.default +#let dk = themes.dark +#let W = 250pt +#let H = 280pt + +#page-grid(cols: 2, rows: 2, col-gutter: 16pt, row-gutter: 24pt, ( + slope-chart( + (..sales.slope, start-label: "H1", end-label: "H2"), + width: W, height: H, title: "slope-chart", theme: lt, + ), + dumbbell-chart( + (..sales.dumbbell, start-label: "Q1", end-label: "Q4"), + width: W, height: H, title: "dumbbell-chart", show-values: true, theme: dk, + ), + lollipop-chart(codebase.subsystems, + width: W, height: H, title: "lollipop-chart", x-label: "Stat", y-label: "Value", theme: lt, + ), + box(fill: rgb("#1a1a2e"), inset: 8pt, radius: 4pt)[ + #text(size: 9pt, weight: "bold", fill: rgb("#e0e0e0"))[bullet-chart (dark)] + #v(6pt) + #bullet-chart(sales.revenue-target.actual, sales.revenue-target.target, sales.revenue-target.ranges, width: 210pt, height: 28pt, title: "Revenue", theme: dk) + #v(6pt) + #bullet-chart(82, 90, (60, 80, 100), width: 210pt, height: 28pt, title: "Satisfaction", theme: dk) + #v(6pt) + #bullet-chart(45, 50, (25, 40, 60), width: 210pt, height: 28pt, title: "Customers", theme: dk) + ], +)) diff --git a/packages/preview/primaviz/0.12.0/examples/demos/demo-dashboard.typ b/packages/preview/primaviz/0.12.0/examples/demos/demo-dashboard.typ new file mode 100644 index 0000000000..c0ed049701 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/examples/demos/demo-dashboard.typ @@ -0,0 +1,89 @@ +// Dashboard: metric-row, word-cloud, sparklines-table, progress-bars +#import "@preview/primaviz:0.12.0": * +#import "../demo-data.typ": sales, words +#set page(margin: 0.5cm, paper: "a4") +#set text(size: 8pt) + +#let lt = themes.default +#let dk = themes.dark +#let W = 250pt + +#page-grid(cols: 2, rows: 2, col-gutter: 16pt, row-gutter: 24pt, ( + [ + #align(center, text(size: 9pt, weight: "bold")[metric-row]) + #v(4pt) + #metric-row(sales.kpis, + width: 250pt, gap: 5pt, theme: lt, + ) + ], + word-cloud(words, + width: W, height: 300pt, title: "word-cloud (dark, circle)", shape: "circle", max-size: 22pt, theme: dk, + ), + [ + #align(center, text(size: 9pt, weight: "bold")[sparkline / sparkbar / sparkdot]) + #v(4pt) + #table( + columns: (auto, auto, auto, auto), + align: (left, center, center, center), + inset: 3pt, + [*Metric*], [*sparkline*], [*sparkbar*], [*sparkdot*], + [Networking], [#sparkline(sales.sparklines.networking, color: rgb("#4e79a7"), width: 50pt, height: 12pt)], [#sparkbar(sales.sparklines.networking, color: rgb("#f28e2b"), width: 50pt, height: 12pt)], [#sparkdot(sales.sparklines.networking, color: rgb("#e15759"), width: 50pt, height: 12pt)], + [Memory], [#sparkline(sales.sparklines.memory, color: rgb("#4e79a7"), width: 50pt, height: 12pt)], [#sparkbar(sales.sparklines.memory, color: rgb("#f28e2b"), width: 50pt, height: 12pt)], [#sparkdot(sales.sparklines.memory, color: rgb("#59a14f"), width: 50pt, height: 12pt)], + [Storage], [#sparkline(sales.sparklines.storage, color: rgb("#4e79a7"), width: 50pt, height: 12pt)], [#sparkbar(sales.sparklines.storage, color: rgb("#f28e2b"), width: 50pt, height: 12pt)], [#sparkdot(sales.sparklines.storage, color: rgb("#59a14f"), width: 50pt, height: 12pt)], + ) + ], + progress-bars(sales.targets, + width: W, title: "progress-bars", max-val: 100, theme: lt, + ), +)) + +#pagebreak() + +// Page 2: card, compact-table, alert, badge, separator +#let cp = themes.compact + +#page-grid(cols: 2, rows: 2, col-gutter: 16pt, row-gutter: 24pt, ( + card(title: "Quarterly Revenue", desc: "By plan tier", width: W, theme: lt)[ + #compact-table( + ("Plan", "Q1", "Q2", "Q3", "Q4"), + ( + ("Enterprise", "120", "150", "180", "210"), + ("Growth", "80", "95", "110", "140"), + ("Starter", "40", "45", "50", "55"), + ), + highlight-col: 4, + theme: lt, + ) + ], + card(title: "System Alerts", desc: "Status notifications", width: W, theme: dk)[ + #alert([Deployment pipeline running.], variant: "info", title: "Info", theme: dk) + #alert([CPU usage above 85%.], variant: "warning", title: "Warning", theme: dk) + #alert([Build failed on staging.], variant: "error", title: "Error", theme: dk) + #alert([All tests passing.], variant: "success", title: "Success", theme: dk) + ], + [ + #align(center, text(size: 9pt, weight: "bold")[badges + separator]) + #v(4pt) + // The badges are 8pt type, the theme's smallest: the row wraps, with room between its lines + #block[ + #set par(leading: 7pt) + #badge("Default", variant: "default", theme: lt) + #h(3pt) + #badge("Secondary", variant: "secondary", theme: lt) + #h(3pt) + #badge("Destructive", variant: "destructive", theme: lt) + #h(3pt) + #badge("Outline", variant: "outline", theme: lt) + #h(3pt) + #badge("Success", variant: "success", theme: lt) + ] + #separator(theme: lt) + #text(size: 8pt)[Badges indicate status, categories, or counts. Use the separator to divide sections visually.] + ], + card(title: "Expenses Breakdown", width: W, theme: cp)[ + #bar-chart(sales.expenses, + width: 100%, height: 160pt, + title: none, theme: cp, + ) + ], +)) diff --git a/packages/preview/primaviz/0.12.0/examples/demos/demo-dual-axis.typ b/packages/preview/primaviz/0.12.0/examples/demos/demo-dual-axis.typ new file mode 100644 index 0000000000..80c42c2dd7 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/examples/demos/demo-dual-axis.typ @@ -0,0 +1,25 @@ +// Dual-axis chart: light, dark, presentation, minimal +#import "@preview/primaviz:0.12.0": * +#import "../demo-data.typ": sales +#set page(margin: 0.5cm, paper: "a4") +#set text(size: 8pt) + +#let lt = themes.default +#let dk = themes.dark +#let W = 250pt +#let H = 280pt + +#page-grid(cols: 2, rows: 2, col-gutter: 16pt, row-gutter: 24pt, ( + dual-axis-chart(sales.dual-axis, + width: W, height: H, title: "dual-axis (light)", left-label: "Revenue ($K)", right-label: "Users (K)", x-label: "Month", theme: lt, + ), + dual-axis-chart(sales.dual-axis, + width: W, height: H, title: "dual-axis (dark)", left-label: "Revenue ($K)", right-label: "Users (K)", x-label: "Month", theme: dk, + ), + dual-axis-chart(sales.dual-axis, + width: W, height: H, title: "dual-axis (presentation)", left-label: "Revenue ($K)", right-label: "Users (K)", x-label: "Month", theme: themes.presentation, + ), + dual-axis-chart(sales.dual-axis, + width: W, height: H, title: "dual-axis (minimal)", left-label: "Revenue ($K)", right-label: "Users (K)", x-label: "Month", theme: themes.minimal, + ), +)) diff --git a/packages/preview/primaviz/0.12.0/examples/demos/demo-flow.typ b/packages/preview/primaviz/0.12.0/examples/demos/demo-flow.typ new file mode 100644 index 0000000000..e0d85232c0 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/examples/demos/demo-flow.typ @@ -0,0 +1,25 @@ +// Flow & timeline: sankey, gantt, timeline, chord +#import "@preview/primaviz:0.12.0": * +#import "../demo-data.typ": sales, codebase +#set page(margin: 0.5cm, paper: "a4") +#set text(size: 8pt) + +#let lt = themes.default +#let dk = themes.dark +#let W = 250pt +#let H = 280pt + +#page-grid(cols: 2, rows: 2, col-gutter: 16pt, row-gutter: 24pt, ( + sankey-chart(sales.budget-flow, + width: W, height: H, title: "sankey-chart", show-labels: true, theme: lt, + ), + gantt-chart(sales.schedule, + width: W, bar-height: 14pt, gap: 3pt, today: 7, title: "gantt-chart (dark)", x-label: "Week", theme: dk, + ), + timeline-chart(sales.milestones, + width: W, event-gap: 45pt, title: "timeline-chart", theme: lt, + ), + chord-diagram(codebase.dependencies, + size: 220pt, arc-width: 12pt, title: "chord-diagram", theme: dk, + ), +)) diff --git a/packages/preview/primaviz/0.12.0/examples/demos/demo-gauge.typ b/packages/preview/primaviz/0.12.0/examples/demos/demo-gauge.typ new file mode 100644 index 0000000000..cb5787cb89 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/examples/demos/demo-gauge.typ @@ -0,0 +1,47 @@ +// Gauge & progress: gauge, dark gauge, progress-bar, circular-progress +#import "@preview/primaviz:0.12.0": * +#import "../demo-data.typ": sales +#set page(margin: 0.5cm, paper: "a4") +#set text(size: 8pt) + +#let lt = themes.default +#let dk = themes.dark + +#page-grid(cols: 2, rows: 2, col-gutter: 16pt, row-gutter: 24pt, ( + [ + #align(center, text(size: 9pt, weight: "bold")[gauge-chart (light)]) + #v(4pt) + #grid(columns: (1fr, 1fr, 1fr), + gauge-chart(sales.conversion-rate, size: 80pt, title: "Conversion", label: "rate", theme: lt), + gauge-chart(sales.uptime, size: 80pt, title: "Uptime", label: "%", theme: lt), + gauge-chart(sales.nps, size: 80pt, title: "NPS", label: "score", theme: lt), + ) + ], + box(fill: rgb("#1a1a2e"), inset: 8pt, radius: 4pt)[ + #text(size: 9pt, weight: "bold", fill: rgb("#e0e0e0"))[gauge-chart (dark)] + #v(4pt) + #grid(columns: (1fr, 1fr, 1fr), + gauge-chart(sales.conversion-rate, size: 80pt, title: "Conversion", label: "rate", theme: dk), + gauge-chart(sales.uptime, size: 80pt, title: "Uptime", label: "%", theme: dk), + gauge-chart(sales.nps, size: 80pt, title: "NPS", label: "score", theme: dk), + ) + ], + [ + #align(center, text(size: 9pt, weight: "bold")[progress-bar]) + #v(4pt) + #progress-bar(sales.targets.values.at(0), width: 230pt, title: "Sales", theme: lt) + #v(6pt) + #progress-bar(sales.targets.values.at(1), width: 230pt, title: "Engineering", color: rgb("#f28e2b"), theme: lt) + #v(6pt) + #progress-bar(sales.targets.values.at(2), width: 230pt, title: "Marketing", color: rgb("#e15759"), theme: lt) + ], + [ + #align(center, text(size: 9pt, weight: "bold")[circular-progress]) + #v(4pt) + #grid(columns: (1fr, 1fr, 1fr), + circular-progress(sales.targets.values.at(0), size: 80pt, title: "Sales", theme: lt), + circular-progress(sales.targets.values.at(1), size: 80pt, title: "Eng", color: rgb("#f28e2b"), theme: lt), + circular-progress(sales.targets.values.at(2), size: 80pt, title: "Mktg", color: rgb("#59a14f"), theme: lt), + ) + ], +)) diff --git a/packages/preview/primaviz/0.12.0/examples/demos/demo-heatmap.typ b/packages/preview/primaviz/0.12.0/examples/demos/demo-heatmap.typ new file mode 100644 index 0000000000..342a6d039f --- /dev/null +++ b/packages/preview/primaviz/0.12.0/examples/demos/demo-heatmap.typ @@ -0,0 +1,23 @@ +// Heatmaps: heatmap light + dark, calendar-heatmap, correlation-matrix +#import "@preview/primaviz:0.12.0": * +#import "../demo-data.typ": sales, codebase +#set page(margin: 0.5cm, paper: "a4") +#set text(size: 8pt) + +#let lt = themes.default +#let dk = themes.dark + +#page-grid(cols: 2, rows: 2, col-gutter: 16pt, row-gutter: 24pt, ( + heatmap(sales.activity, + cell-size: 48pt, title: "heatmap (light)", palette: "viridis", theme: lt, + ), + heatmap(sales.activity, + cell-size: 48pt, title: "heatmap (dark)", palette: "viridis", theme: dk, + ), + calendar-heatmap(sales.daily-deals, + cell-size: 16pt, title: "calendar-heatmap", palette: "heat", theme: lt, + ), + correlation-matrix(codebase.correlation, + cell-size: 48pt, title: "correlation-matrix", theme: dk, + ), +)) diff --git a/packages/preview/primaviz/0.12.0/examples/demos/demo-line.typ b/packages/preview/primaviz/0.12.0/examples/demos/demo-line.typ new file mode 100644 index 0000000000..309a985cc9 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/examples/demos/demo-line.typ @@ -0,0 +1,25 @@ +// Line charts: single + multi-line, light + dark +#import "@preview/primaviz:0.12.0": * +#import "../demo-data.typ": sales +#set page(margin: 0.5cm, paper: "a4") +#set text(size: 8pt) + +#let lt = themes.default +#let dk = themes.dark +#let W = 250pt +#let H = 280pt + +#page-grid(cols: 2, rows: 2, col-gutter: 16pt, row-gutter: 24pt, ( + line-chart(sales.monthly, + width: W, height: H, title: "line-chart (light)", show-points: true, x-label: "Session", y-label: "Revenue ($K)", theme: lt, + ), + line-chart(sales.monthly, + width: W, height: H, title: "line-chart (dark)", show-points: true, x-label: "Session", y-label: "Revenue ($K)", theme: dk, + ), + multi-line-chart(sales.monthly-series, + width: W, height: H, title: "multi-line-chart (light)", x-label: "Gameweek", y-label: "Goals", theme: lt, + ), + multi-line-chart(sales.monthly-series, + width: W, height: H, title: "multi-line-chart (dark)", x-label: "Gameweek", y-label: "Goals", theme: dk, + ), +)) diff --git a/packages/preview/primaviz/0.12.0/examples/demos/demo-misc.typ b/packages/preview/primaviz/0.12.0/examples/demos/demo-misc.typ new file mode 100644 index 0000000000..f8467ae0b6 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/examples/demos/demo-misc.typ @@ -0,0 +1,23 @@ +// Miscellaneous: waffle, parliament, radial-bar, sunburst +#import "@preview/primaviz:0.12.0": * +#import "../demo-data.typ": codebase, sales +#set page(margin: 0.5cm, paper: "a4") +#set text(size: 8pt) + +#let lt = themes.default +#let dk = themes.dark + +#page-grid(cols: 2, rows: 2, col-gutter: 16pt, row-gutter: 24pt, ( + waffle-chart(codebase.languages, + size: 200pt, gap: 1.5pt, title: "waffle-chart (light)", theme: lt, + ), + parliament-chart(codebase.contributors, + size: 210pt, dot-size: 4pt, title: "parliament-chart (dark)", theme: dk, + ), + radial-bar-chart(codebase.health, + size: 200pt, title: "radial-bar-chart (light)", show-labels: true, theme: lt, + ), + sunburst-chart(sales.org, + size: 200pt, inner-radius: 25pt, ring-width: 40pt, title: "sunburst-chart (dark)", theme: dk, + ), +)) diff --git a/packages/preview/primaviz/0.12.0/examples/demos/demo-pie.typ b/packages/preview/primaviz/0.12.0/examples/demos/demo-pie.typ new file mode 100644 index 0000000000..7f62f13cae --- /dev/null +++ b/packages/preview/primaviz/0.12.0/examples/demos/demo-pie.typ @@ -0,0 +1,23 @@ +// Pie charts: standard + donut, light + dark +#import "@preview/primaviz:0.12.0": * +#import "../demo-data.typ": codebase, sales +#set page(margin: 0.5cm, paper: "a4") +#set text(size: 8pt) + +#let lt = themes.default +#let dk = themes.dark + +#page-grid(cols: 2, rows: 2, col-gutter: 16pt, row-gutter: 24pt, ( + pie-chart(codebase.languages, + size: 220pt, title: "pie-chart (light)", theme: lt, + ), + pie-chart(codebase.languages, + size: 220pt, title: "pie-chart (dark)", theme: dk, + ), + pie-chart(sales.expenses, + size: 220pt, title: "donut (light)", donut: true, donut-ratio: 0.5, theme: lt, + ), + pie-chart(sales.expenses, + size: 220pt, title: "donut (dark)", donut: true, donut-ratio: 0.5, theme: dk, + ), +)) diff --git a/packages/preview/primaviz/0.12.0/examples/demos/demo-radar.typ b/packages/preview/primaviz/0.12.0/examples/demos/demo-radar.typ new file mode 100644 index 0000000000..fce23cc006 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/examples/demos/demo-radar.typ @@ -0,0 +1,28 @@ +// Radar charts: light, dark, 3-series, accessible +#import "@preview/primaviz:0.12.0": * +#import "../demo-data.typ": league +#set page(margin: 0.5cm, paper: "a4") +#set text(size: 8pt) + +#let lt = themes.default +#let dk = themes.dark + +#let two-series = ( + labels: league.player-stats.labels, + series: league.player-stats.series.slice(0, 2), +) + +#page-grid(cols: 2, rows: 2, col-gutter: 16pt, row-gutter: 24pt, ( + radar-chart(two-series, + size: 200pt, title: "radar-chart (light)", fill-opacity: 15%, theme: lt, + ), + radar-chart(two-series, + size: 200pt, title: "radar-chart (dark)", fill-opacity: 15%, theme: dk, + ), + radar-chart(league.player-stats, + size: 200pt, title: "radar-chart (3-series)", fill-opacity: 15%, theme: lt, + ), + radar-chart(league.player-stats, + size: 200pt, title: "radar-chart (accessible)", fill-opacity: 15%, theme: themes.accessible, + ), +)) diff --git a/packages/preview/primaviz/0.12.0/examples/demos/demo-rings.typ b/packages/preview/primaviz/0.12.0/examples/demos/demo-rings.typ new file mode 100644 index 0000000000..4d9b19f215 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/examples/demos/demo-rings.typ @@ -0,0 +1,25 @@ +// Rings & treemap: ring-progress light + dark, treemap light + dark +#import "@preview/primaviz:0.12.0": * +#import "../demo-data.typ": league, sales +#set page(margin: 0.5cm, paper: "a4") +#set text(size: 8pt) + +#let lt = themes.default +#let dk = themes.dark +#let W = 250pt +#let H = 280pt + +#page-grid(cols: 2, rows: 2, col-gutter: 16pt, row-gutter: 24pt, ( + ring-progress(league.season-targets, + size: 180pt, ring-width: 16pt, title: "ring-progress (light)", theme: lt, + ), + ring-progress(league.season-targets, + size: 180pt, ring-width: 16pt, title: "ring-progress (dark)", theme: dk, + ), + treemap(sales.expenses, + width: W, height: H, title: "treemap (light)", theme: lt, + ), + treemap(sales.expenses, + width: W, height: H, title: "treemap (dark)", theme: dk, + ), +)) diff --git a/packages/preview/primaviz/0.12.0/examples/demos/demo-scaling.typ b/packages/preview/primaviz/0.12.0/examples/demos/demo-scaling.typ new file mode 100644 index 0000000000..6613818cb0 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/examples/demos/demo-scaling.typ @@ -0,0 +1,53 @@ +// Golden-ratio scaling demo: everything auto-sizes from two seed values +#import "@preview/primaviz:0.12.0": * +#import "../demo-data.typ": sales +#set page(margin: 0.4cm, paper: "a4") +#set text(size: 7pt) + +#let sample = (labels: sales.monthly.labels.slice(0, 4), values: sales.monthly.values.slice(0, 4)) + +// Just change seeds — container, padding, text, strokes all follow +#let scales = ( + (name: "0.5× (4pt / 3pt)", bs: 4pt, bg: 3pt), + (name: "1× default (8pt / 6pt)", bs: 8pt, bg: 6pt), + (name: "2× (16pt / 12pt)", bs: 16pt, bg: 12pt), +) + +#align(center, text(size: 11pt, weight: "bold")[φ-Scaling: two seeds control everything]) +#v(2pt) +#align(center, text(size: 7.5pt, fill: luma(100))[No manual width, height, or cell-size — charts self-size from `base-size` and `base-gap`]) +#v(6pt) + +// Bar charts — auto width & height from seeds +#for s in scales { + bar-chart(sample, title: s.name, y-label: "Rev", + theme: (base-size: s.bs, base-gap: s.bg, show-grid: true)) + v(6pt) +} + +#pagebreak() +#align(center, text(size: 11pt, weight: "bold")[φ-Scaling: line charts]) +#v(6pt) + +#for s in scales { + line-chart(sample, title: s.name, y-label: "Rev", + theme: (base-size: s.bs, base-gap: s.bg, show-grid: true)) + v(6pt) +} + +#pagebreak() +#align(center, text(size: 11pt, weight: "bold")[φ-Scaling: heatmaps]) +#v(6pt) + +#let heat-data = ( + rows: ("A", "B", "C"), + cols: ("X", "Y", "Z"), + values: ((82, 95, 78), (45, 52, 68), (33, 41, 55)), +) + +// cell-size auto-derived from seeds — no manual value needed +#for s in scales { + heatmap(heat-data, title: s.name, + theme: (base-size: s.bs, base-gap: s.bg)) + v(6pt) +} diff --git a/packages/preview/primaviz/0.12.0/examples/demos/demo-scatter.typ b/packages/preview/primaviz/0.12.0/examples/demos/demo-scatter.typ new file mode 100644 index 0000000000..010dc99406 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/examples/demos/demo-scatter.typ @@ -0,0 +1,30 @@ +// Scatter charts: scatter, dark scatter, multi-scatter, bubble +#import "@preview/primaviz:0.12.0": * +#import "../demo-data.typ": league +#set page(margin: 0.5cm, paper: "a4") +#set text(size: 8pt) + +#let lt = themes.default +#let dk = themes.dark +#let W = 250pt +#let H = 280pt + +#page-grid(cols: 2, rows: 2, col-gutter: 16pt, row-gutter: 24pt, ( + scatter-plot(league.team-scatter, + width: W, height: H, title: "scatter-plot (light)", + x-label: "Goals Scored", y-label: "Goals Conceded", theme: lt, + ), + scatter-plot(league.team-scatter, + width: W, height: H, title: "scatter-plot (dark)", + x-label: "Goals Scored", y-label: "Goals Conceded", theme: dk, + ), + multi-scatter-plot(league.home-away, + width: W, height: H, title: "multi-scatter-plot", + x-label: "Goals Scored", y-label: "Goals Conceded", theme: lt, + ), + bubble-chart(league.team-bubble, + width: W, height: H, title: "bubble-chart (dark)", + x-label: "Goals", y-label: "Possession %", + show-labels: true, labels: league.team-bubble.labels, theme: dk, + ), +)) diff --git a/packages/preview/primaviz/0.12.0/examples/demos/demo-statistical.typ b/packages/preview/primaviz/0.12.0/examples/demos/demo-statistical.typ new file mode 100644 index 0000000000..e0fd6b2994 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/examples/demos/demo-statistical.typ @@ -0,0 +1,25 @@ +// Statistical: histogram, box-plot, violin, waterfall +#import "@preview/primaviz:0.12.0": * +#import "../demo-data.typ": league, sales +#set page(margin: 0.5cm, paper: "a4") +#set text(size: 8pt) + +#let lt = themes.default +#let dk = themes.dark +#let W = 250pt +#let H = 280pt + +#page-grid(cols: 2, rows: 2, col-gutter: 16pt, row-gutter: 24pt, ( + histogram(league.goals-per-match, + width: W, height: H, title: "histogram", bins: 12, x-label: "Goals", y-label: "Frequency", theme: lt, + ), + box-plot(league.minutes-played, + width: W, height: H, title: "box-plot", show-grid: true, x-label: "Position", y-label: "Minutes", theme: dk, + ), + violin-plot(league.ratings, + width: W, height: H, title: "violin-plot", y-label: "Rating", x-label: "Width = density", show-grid: true, theme: lt, + ), + waterfall-chart(sales.waterfall, + width: W, height: H, title: "waterfall-chart", x-label: "Stage", y-label: "Gold", theme: dk, + ), +)) diff --git a/packages/preview/primaviz/0.12.0/examples/demos/demo-themes.typ b/packages/preview/primaviz/0.12.0/examples/demos/demo-themes.typ new file mode 100644 index 0000000000..560cde4c6e --- /dev/null +++ b/packages/preview/primaviz/0.12.0/examples/demos/demo-themes.typ @@ -0,0 +1,55 @@ +// Theme comparison: same charts across all 6 themes + with-theme demo +#import "@preview/primaviz:0.12.0": * +#import "../demo-data.typ": sales +#set page(margin: 0.4cm, paper: "a4") +#set text(size: 7pt) + +#let sample = (labels: sales.monthly.labels.slice(0, 4), values: sales.monthly.values.slice(0, 4)) +#let W = 170pt +#let H = 100pt + +#let theme-list = ( + ("default", themes.default), + ("minimal", themes.minimal), + ("dark", themes.dark), + ("presentation", themes.presentation), + ("print", themes.print), + ("accessible", themes.accessible), +) + +// Row 1: bar chart across all 6 themes +#grid( + columns: (1fr, 1fr, 1fr), + column-gutter: 6pt, + row-gutter: 6pt, + ..theme-list.map(((name, t)) => { + bar-chart(sample, width: W, height: H, title: name, y-label: "Value", theme: t) + }) +) + +#v(4pt) + +// Row 2: line chart across all 6 themes +#grid( + columns: (1fr, 1fr, 1fr), + column-gutter: 6pt, + row-gutter: 6pt, + ..theme-list.map(((name, t)) => { + line-chart(sample, width: W, height: H, title: name, y-label: "Value", theme: t) + }) +) + +#v(4pt) + +// Row 3: with-theme — all charts inherit dark theme +#text(size: 8pt, weight: "bold")[with-theme: all charts below inherit dark theme] +#v(2pt) +#with-theme(themes.dark)[ + #grid( + columns: (1fr, 1fr, 1fr), + column-gutter: 6pt, + bar-chart(sample, width: W, height: H, title: "bar (inherited)"), + line-chart(sample, width: W, height: H, title: "line (inherited)"), + pie-chart(sample, size: 70pt, title: "pie (inherited)"), + ) +] diff --git a/packages/preview/primaviz/0.12.0/examples/showcase.typ b/packages/preview/primaviz/0.12.0/examples/showcase.typ new file mode 100644 index 0000000000..0e62f16316 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/examples/showcase.typ @@ -0,0 +1,390 @@ +// Compact showcase — All chart types using page-grid layout engine +#import "@preview/primaviz:0.12.0": * +#import "demo-data.typ": sales, codebase, league, rpg, words + +#set page(margin: (x: 0.6cm, y: 0.6cm), paper: "a4", fill: themes.dark.background) +#set text(size: 7pt, fill: themes.dark.text-color) + +#let dk = themes.dark +#let gk = (..dk, title-size: 7pt) + +// Standard cell sizes for 2-col × 4-row layout on A4 +#let W = 250pt +#let H = 100pt +#let Hs = 95pt + +// ── All charts as a flat array ───────────────────────────────────────────────── + +#page-grid(cols: 2, rows: 4, ( + + // 1. bar-chart — RPG: kills by character + bar-chart( + (labels: rpg.characters.map(c => c.name), values: rpg.characters.map(c => c.kills)), + width: W, height: H, title: "bar-chart", y-label: "Kills", theme: dk, + ), + + // 2. horizontal-bar-chart — Codebase: patches by subsystem + horizontal-bar-chart(codebase.patches, + width: W, height: H, title: "horizontal-bar-chart", x-label: "Patches", theme: dk, + ), + + // 3. grouped-bar-chart — League: goals/assists/cards by team + grouped-bar-chart(league.team-stats, + width: W, height: H, title: "grouped-bar-chart", y-label: "Count", theme: dk, + ), + + // 4. stacked-bar-chart — Sales: quarterly revenue by product + stacked-bar-chart(sales.quarterly, + width: W, height: H, title: "stacked-bar-chart", y-label: "Revenue ($K)", theme: dk, + ), + + // 5. line-chart — RPG: party gold over sessions + line-chart(rpg.party-gold, + width: W, height: Hs, title: "line-chart", show-points: true, x-label: "Session", y-label: "Gold", theme: dk, + ), + + // 6. multi-line-chart — League: goals per gameweek + multi-line-chart(league.weekly-goals, + width: W, height: Hs, title: "multi-line-chart", x-label: "Gameweek", y-label: "Goals", theme: dk, + ), + + // 7. area-chart — Sales: monthly revenue + area-chart(sales.monthly, + width: W, height: Hs, title: "area-chart", fill-opacity: 40%, x-label: "Month", y-label: "Revenue ($K)", theme: dk, + ), + + // 8. stacked-area-chart — Codebase: commits by language + stacked-area-chart(codebase.commits-by-lang, + width: W, height: Hs, title: "stacked-area-chart", x-label: "Month", y-label: "Commits", theme: dk, + ), + + // ── page 2 ── + + // 9. pie-chart — Codebase: language proportions + pie-chart(codebase.languages, + size: 85pt, title: "pie-chart", theme: dk, + ), + + // 10. pie-chart (donut) — RPG: loot breakdown + pie-chart(rpg.loot-breakdown, + size: 85pt, title: "pie-chart (donut)", donut: true, donut-ratio: 0.35, theme: dk, + ), + + // 11. radar-chart — League: player skill profiles + radar-chart(league.player-stats, + size: 150pt, title: "radar-chart", fill-opacity: 15%, theme: dk, + ), + + // 12. scatter-plot — RPG: HP vs Kills + scatter-plot( + (x: rpg.characters.map(c => c.hp), y: rpg.characters.map(c => c.kills)), + width: W, height: Hs, title: "scatter-plot", + x-label: "Hit Points", y-label: "Kills", theme: dk, + ), + + // 13. multi-scatter-plot — League: home vs away + multi-scatter-plot(league.home-away, + width: W, height: Hs, title: "multi-scatter-plot", + x-label: "Goals Scored", y-label: "Goals Conceded", theme: dk, + ), + + // 14. bubble-chart — League: goals × possession × market value + bubble-chart(league.team-bubble, + width: W, height: Hs, title: "bubble-chart", + x-label: "Goals", y-label: "Possession %", + size-label: "Value (£M)", + show-labels: true, labels: league.team-bubble.labels, theme: dk, + ), + + // 15. gauge-chart — Sales: conversion/uptime/NPS + [ + #v(10pt) + #align(center, text(size: dk.title-size, weight: dk.title-weight, fill: dk.text-color)[gauge-chart]) + #v(2pt) + #grid( + columns: (1fr, 1fr, 1fr), + gauge-chart(sales.conversion-rate, size: 55pt, title: "Conversion", label: "rate", theme: gk), + gauge-chart(sales.uptime, size: 55pt, title: "Uptime", label: "%", theme: gk), + gauge-chart(sales.nps, size: 55pt, title: "NPS", label: "score", theme: gk), + ) + ], + + // 16. progress-bar + circular-progress — RPG: quest progress + [ + #v(10pt) + #align(center, text(size: dk.title-size, weight: dk.title-weight, fill: dk.text-color)[progress-bar · circular-progress]) + #v(3pt) + #progress-bar(rpg.quest-completion, width: 230pt, title: "Main Quest", theme: dk) + #v(3pt) + #grid( + columns: (1fr, 1fr, 1fr), + circular-progress(rpg.quest-completion, size: 50pt, title: "Quest", theme: dk), + circular-progress(rpg.xp-progress, size: 50pt, title: "XP", color: get-color(dk, 1), theme: dk), + circular-progress(rpg.party-health, size: 50pt, title: "Health", color: get-color(dk, 4), theme: dk), + ) + ], + + // ── page 3 ── + + // 17. ring-progress — League: season targets + ring-progress(league.season-targets, + size: 85pt, ring-width: 8pt, title: "ring-progress", theme: dk, + ), + + // 18. dual-axis-chart — Sales: revenue + users + dual-axis-chart(sales.dual-axis, + width: W, height: Hs, title: "dual-axis-chart", theme: dk, + ), + + // 19. histogram — League: goals per match distribution + histogram(league.goals-per-match, + width: W, height: Hs, title: "histogram", bins: 12, x-label: "Goals/Match", y-label: "Frequency", theme: dk, + ), + + // 20. waterfall-chart — RPG: dungeon loot P&L + waterfall-chart(rpg.dungeon-loot, + width: W, height: Hs, title: "waterfall-chart", show-legend: true, x-label: "Stage", y-label: "Gold", theme: dk, + ), + + // 21. funnel-chart — Sales: pipeline + funnel-chart(sales.funnel, + width: W, height: 110pt, title: "funnel-chart", theme: dk, + ), + + // 22. box-plot — League: minutes played by position + box-plot(league.minutes-played, + width: W, height: 110pt, title: "box-plot", show-grid: true, x-label: "Position", y-label: "Minutes", theme: dk, + ), + + // 23. heatmap — RPG: skill usage by character + heatmap(rpg.skill-usage, + cell-size: 22pt, title: "heatmap", palette: "viridis", theme: dk, + ), + + // 24. correlation-matrix — Codebase: module coupling + correlation-matrix(codebase.correlation, + cell-size: 22pt, title: "correlation-matrix", theme: dk, + ), + + // ── page 4 ── + + // 25. calendar-heatmap — League: match-day total goals + calendar-heatmap(league.match-calendar, + cell-size: 6.5pt, title: "calendar-heatmap", palette: "heat", theme: dk, + ), + + // 26. progress-bars — Sales: department targets + progress-bars(sales.targets, + width: W, title: "progress-bars", max-val: 100, theme: dk, + ), + + // 27. sparklines — Sales: server metrics + [ + #let c0 = get-color(dk, 0) + #let c1 = get-color(dk, 1) + #let c2 = get-color(dk, 5) + #let sw = 50pt + #let sh = 12pt + #v(10pt) + #align(center, text(size: dk.title-size, weight: dk.title-weight, fill: dk.text-color)[sparkline · sparkbar · sparkdot]) + #v(2pt) + #align(center, table( + columns: (auto, auto, auto, auto), + align: (left, center, center, center), + inset: 3pt, + stroke: dk.grid-stroke, + fill: dk.background, + [*Metric*], [*sparkline*], [*sparkbar*], [*sparkdot*], + [Networking], [#sparkline(sales.sparklines.networking, color: c0, width: sw, height: sh)], [#sparkbar(sales.sparklines.networking, color: c2, width: sw, height: sh)], [#sparkdot(sales.sparklines.networking, color: c1, width: sw, height: sh)], + [Memory], [#sparkline(sales.sparklines.memory, color: c0, width: sw, height: sh)], [#sparkbar(sales.sparklines.memory, color: c2, width: sw, height: sh)], [#sparkdot(sales.sparklines.memory, color: c1, width: sw, height: sh)], + [Storage], [#sparkline(sales.sparklines.storage, color: c0, width: sw, height: sh)], [#sparkbar(sales.sparklines.storage, color: c2, width: sw, height: sh)], [#sparkdot(sales.sparklines.storage, color: c1, width: sw, height: sh)], + )) + ], + + // 28. treemap — League: squad value by team + treemap(league.squad-value, + width: W, height: 110pt, title: "treemap", theme: dk, + ), + + // 29. sankey-chart — Sales: budget allocation flow + sankey-chart(sales.budget-flow, + width: W, height: 110pt, title: "sankey-chart", show-labels: true, theme: dk, + ), + + // 30. lollipop-chart — RPG: damage dealt + lollipop-chart(rpg.damage-dealt, + width: W, height: Hs, title: "lollipop-chart", x-label: "Character", y-label: "Damage", theme: dk, + ), + + // 31. horizontal-lollipop-chart — Codebase: PR merge time + horizontal-lollipop-chart(codebase.pr-merge-time, + width: W, height: Hs, title: "horizontal-lollipop-chart", x-label: "Days", theme: dk, + ), + + // 32. diverging-bar-chart — League: home vs away win rate + diverging-bar-chart( + (..league.home-vs-away-pct, left-label: "Away %", right-label: "Home %"), + width: W, height: Hs, title: "diverging-bar-chart", x-label: "Win Rate", theme: dk, + ), + + // ── page 5 ── + + // 33. slope-chart — League: start → end season rankings + slope-chart( + (..league.season-slope, start-label: "GW1", end-label: "GW38"), + width: W, height: 105pt, title: "slope-chart", theme: dk, + ), + + // 34. bullet-chart — Sales: revenue/satisfaction/customers + [ + #v(10pt) + #align(center, text(size: dk.title-size, weight: dk.title-weight, fill: dk.text-color)[bullet-chart]) + #v(2pt) + #bullet-chart(sales.revenue-target.actual, sales.revenue-target.target, sales.revenue-target.ranges, width: 230pt, height: 22pt, title: "Revenue", theme: dk) + #v(2pt) + #bullet-chart(82, 90, (60, 80, 100), width: 230pt, height: 22pt, title: "Satisfaction", theme: dk) + #v(2pt) + #bullet-chart(45, 50, (25, 40, 60), width: 230pt, height: 22pt, title: "Customers", theme: dk) + ], + + // 35. grouped-stacked-bar-chart — Sales: product × channel + grouped-stacked-bar-chart(sales.channels, + width: W, height: H, title: "grouped-stacked-bar-chart", y-label: "Revenue ($K)", theme: dk, + ), + + // 36. gantt-chart — RPG: campaign arc schedule + [ + #v(10pt) + #align(center, text(size: dk.title-size, weight: dk.title-weight, fill: dk.text-color)[gantt-chart]) + #v(2pt) + #gantt-chart(rpg.campaign-arcs, + width: 230pt, bar-height: 10pt, gap: 2pt, today: 7, show-legend: true, title: none, theme: dk, + ) + ], + + // 37. waffle-chart — Codebase: language proportions + waffle-chart(codebase.languages, + size: 120pt, gap: 1pt, title: "waffle-chart", theme: dk, + ), + + // 38. bump-chart — League: team rankings over gameweeks + bump-chart(league.season-rankings, + width: W, height: 110pt, title: "bump-chart", dot-size: 4pt, theme: dk, + ), + + // 39. dumbbell-chart — RPG: stat gains level 1 → 8 + dumbbell-chart( + (..rpg.stat-gains, start-label: "Lv1", end-label: "Lv8"), + width: W, height: 110pt, title: "dumbbell-chart", show-values: true, theme: dk, + ), + + // 40. radial-bar-chart — Codebase: subsystem health + radial-bar-chart(codebase.health, + size: 120pt, title: "radial-bar-chart", show-labels: true, theme: dk, + ), + + // ── page 6 ── + + // 41. sunburst-chart — RPG: class → subclass → specialization + sunburst-chart(rpg.class-tree, + size: 120pt, inner-radius: 18pt, ring-width: 22pt, title: "sunburst-chart", theme: dk, + ), + + // 42. metric-card + metric-row — Sales: KPIs + [ + #v(10pt) + #align(center, text(size: dk.title-size, weight: dk.title-weight, fill: dk.text-color)[metric-card · metric-row]) + #v(3pt) + #metric-row(sales.kpis, + width: 250pt, gap: 5pt, theme: dk, + ) + ], + + // 43. violin-plot — League: player ratings by position + violin-plot(league.ratings, + width: W, height: 100pt, title: "violin-plot", x-label: "Position", y-label: "Rating", theme: dk, + ), + + // 44. timeline-chart — League: season milestones + timeline-chart(league.season-timeline, + width: W, event-gap: 30pt, title: "timeline-chart", theme: dk, + ), + + // 45. parliament-chart — Codebase: contributor types + parliament-chart(codebase.contributors, + size: 130pt, dot-size: 3pt, title: "parliament-chart", theme: dk, + ), + + // 46. chord-diagram — RPG: party interaction matrix + chord-diagram(rpg.interaction-matrix, + size: 130pt, arc-width: 10pt, title: "chord-diagram", theme: dk, + ), + + // 47. card + compact-table — Dashboard layout + [ + #v(10pt) + #align(center, text(size: dk.title-size, weight: dk.title-weight, fill: dk.text-color)[card · compact-table]) + #v(2pt) + #card(title: "Revenue", desc: "Monthly", theme: dk)[ + #compact-table( + ("Month", "Rev", "Cost", "Profit"), + ( + ("Jan", "$12K", "$8K", "$4K"), + ("Feb", "$15K", "$9K", "$6K"), + ("Mar", "$11K", "$7K", "$4K"), + ), + highlight-col: 3, + theme: dk, + ) + ] + ], + + // 48. alert + badge + separator — Dashboard components + [ + #v(10pt) + #align(center, text(size: dk.title-size, weight: dk.title-weight, fill: dk.text-color)[alert · badge · separator]) + #v(2pt) + #alert(title: "Deployed", variant: "success", theme: dk)[Service v2.4.1 live.] + #v(3pt) + #alert(title: "Warning", variant: "warning", theme: dk)[Memory at 87%.] + #v(4pt) + #badge("Production", theme: dk) #h(3pt) + #badge("Staging", variant: "secondary", theme: dk) #h(3pt) + #badge("Failed", variant: "destructive", theme: dk) + #separator(theme: dk) + ], + +)) + +// ── Page 8: φ-scaling demo ─────────────────────────────────────────────────── +#pagebreak() +#let scale-data = (labels: sales.monthly.labels.slice(0, 3), values: sales.monthly.values.slice(0, 3)) +#align(center, text(size: dk.title-size, weight: dk.title-weight, fill: dk.text-color)[φ-Scaling: same data, different seeds]) +#v(4pt) +#align(center, text(size: dk.axis-label-size, fill: dk.text-color)[No manual width, height, or cell-size — charts self-size from `base-size` and `base-gap`]) +#v(6pt) + +#let scales = ( + (name: "0.5× (4pt / 3pt)", bs: 4pt, bg: 3pt), + (name: "1× default (8pt / 6pt)", bs: 8pt, bg: 6pt), + (name: "2× (16pt / 12pt)", bs: 16pt, bg: 12pt), +) + +// 2-column grid: bar (left) × line (right), 3 rows (0.5×, 1×, 2×) +#grid( + columns: (1fr, 1fr), + column-gutter: 8pt, + row-gutter: 6pt, + ..scales.map(s => ( + bar-chart(scale-data, title: s.name, y-label: "Rev", + theme: (..dk, base-size: s.bs, base-gap: s.bg, show-grid: true)), + line-chart(scale-data, title: s.name, y-label: "Rev", + theme: (..dk, base-size: s.bs, base-gap: s.bg, show-grid: true)), + )).flatten() +) + +// ── Page 9: Full-page word cloud ───────────────────────────────────────────── +#pagebreak() +#word-cloud(words, + width: 100%, height: 100%, title: "word-cloud", shape: "circle", theme: dk, +) diff --git a/packages/preview/primaviz/0.12.0/screenshots/demo/demo-annotations-1.png b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-annotations-1.png new file mode 100644 index 0000000000..25b5397100 Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-annotations-1.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/demo/demo-area-1.png b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-area-1.png new file mode 100644 index 0000000000..1c205e8704 Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-area-1.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/demo/demo-bar-1.png b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-bar-1.png new file mode 100644 index 0000000000..2ae4a84099 Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-bar-1.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/demo/demo-bar-advanced-1.png b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-bar-advanced-1.png new file mode 100644 index 0000000000..0de647f5a5 Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-bar-advanced-1.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/demo/demo-bar-multi-1.png b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-bar-multi-1.png new file mode 100644 index 0000000000..c942356132 Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-bar-multi-1.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/demo/demo-bump-1.png b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-bump-1.png new file mode 100644 index 0000000000..dac204176c Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-bump-1.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/demo/demo-comparison-1.png b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-comparison-1.png new file mode 100644 index 0000000000..9cbb0755d8 Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-comparison-1.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/demo/demo-dashboard-1.png b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-dashboard-1.png new file mode 100644 index 0000000000..b57522b6a0 Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-dashboard-1.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/demo/demo-dashboard-2.png b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-dashboard-2.png new file mode 100644 index 0000000000..42b9451a56 Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-dashboard-2.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/demo/demo-dual-axis-1.png b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-dual-axis-1.png new file mode 100644 index 0000000000..26dd031226 Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-dual-axis-1.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/demo/demo-flow-1.png b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-flow-1.png new file mode 100644 index 0000000000..c4026d893e Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-flow-1.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/demo/demo-gauge-1.png b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-gauge-1.png new file mode 100644 index 0000000000..e9d3964e2a Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-gauge-1.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/demo/demo-heatmap-1.png b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-heatmap-1.png new file mode 100644 index 0000000000..92c9310dd1 Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-heatmap-1.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/demo/demo-line-1.png b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-line-1.png new file mode 100644 index 0000000000..2a7c1b418a Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-line-1.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/demo/demo-misc-1.png b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-misc-1.png new file mode 100644 index 0000000000..a4c7c7569a Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-misc-1.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/demo/demo-pie-1.png b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-pie-1.png new file mode 100644 index 0000000000..9519426f59 Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-pie-1.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/demo/demo-radar-1.png b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-radar-1.png new file mode 100644 index 0000000000..93f1d5dd62 Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-radar-1.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/demo/demo-rings-1.png b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-rings-1.png new file mode 100644 index 0000000000..8ffd440dba Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-rings-1.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/demo/demo-scaling-1.png b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-scaling-1.png new file mode 100644 index 0000000000..7117e8d5ca Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-scaling-1.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/demo/demo-scaling-2.png b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-scaling-2.png new file mode 100644 index 0000000000..c9b18c11f2 Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-scaling-2.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/demo/demo-scaling-3.png b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-scaling-3.png new file mode 100644 index 0000000000..1472957a68 Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-scaling-3.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/demo/demo-scatter-1.png b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-scatter-1.png new file mode 100644 index 0000000000..98290ca88e Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-scatter-1.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/demo/demo-statistical-1.png b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-statistical-1.png new file mode 100644 index 0000000000..2bc79c7bb8 Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-statistical-1.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/demo/demo-themes-1.png b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-themes-1.png new file mode 100644 index 0000000000..30d0159b5a Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/demo/demo-themes-1.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/showcase/showcase-1.png b/packages/preview/primaviz/0.12.0/screenshots/showcase/showcase-1.png new file mode 100644 index 0000000000..e236be040c Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/showcase/showcase-1.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/showcase/showcase-2.png b/packages/preview/primaviz/0.12.0/screenshots/showcase/showcase-2.png new file mode 100644 index 0000000000..6c928a99b6 Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/showcase/showcase-2.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/showcase/showcase-3.png b/packages/preview/primaviz/0.12.0/screenshots/showcase/showcase-3.png new file mode 100644 index 0000000000..a02ece1de7 Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/showcase/showcase-3.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/showcase/showcase-4.png b/packages/preview/primaviz/0.12.0/screenshots/showcase/showcase-4.png new file mode 100644 index 0000000000..0825719bc1 Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/showcase/showcase-4.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/showcase/showcase-5.png b/packages/preview/primaviz/0.12.0/screenshots/showcase/showcase-5.png new file mode 100644 index 0000000000..29886980e2 Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/showcase/showcase-5.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/showcase/showcase-6.png b/packages/preview/primaviz/0.12.0/screenshots/showcase/showcase-6.png new file mode 100644 index 0000000000..6edb940644 Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/showcase/showcase-6.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/showcase/showcase-7.png b/packages/preview/primaviz/0.12.0/screenshots/showcase/showcase-7.png new file mode 100644 index 0000000000..8ef34f096d Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/showcase/showcase-7.png differ diff --git a/packages/preview/primaviz/0.12.0/screenshots/showcase/showcase-8.png b/packages/preview/primaviz/0.12.0/screenshots/showcase/showcase-8.png new file mode 100644 index 0000000000..730e678ca4 Binary files /dev/null and b/packages/preview/primaviz/0.12.0/screenshots/showcase/showcase-8.png differ diff --git a/packages/preview/primaviz/0.12.0/src/charts/area.typ b/packages/preview/primaviz/0.12.0/src/charts/area.typ new file mode 100644 index 0000000000..a2ee547df7 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/area.typ @@ -0,0 +1,159 @@ +// area.typ - Area charts (single and stacked) +#import "../theme.typ": _resolve-ctx, get-color +#import "../util.typ": normalize-data +#import "../validate.typ": validate-simple-data, validate-series-data +#import "../primitives/frame.typ": cartesian-frame +#import "../primitives/axes.typ": value-axis, category-axis +#import "../primitives/legend.typ": draw-legend-auto, legend-max-width +#import "../primitives/layout.typ": resolve-size +#import "../primitives/paths.typ": draw-polyline + +// Room the first and last point need at the ends of the x axis. +#let _point-inset(show-points, size, line-width) = (if show-points { size } else { line-width }) / 2 + 1pt + +/// Renders a single-series area chart with a filled region below the line. +/// +/// - data (dictionary, array): Label-value pairs as dict or array of tuples +/// - width (length): Chart width +/// - height (length): Chart height +/// - title (none, content): Optional chart title +/// - show-line (bool): Draw the line on top of the filled area +/// - show-points (bool): Draw data point markers +/// - fill-opacity (ratio): Opacity of the filled area +/// - line-width (auto, length): Stroke width of the line; `auto` uses the theme's `stroke-thick` +/// - point-size (length): Diameter of point markers +/// - x-label (none, content): X-axis title +/// - y-label (none, content): Y-axis title +/// - annotations (none, array): Optional annotation descriptors +/// - show-ticks (bool): Draw tick marks on the axis lines +/// - show-minor-grid (bool): Draw lighter minor grid lines between the major ones +/// - theme (none, dictionary): Theme overrides +/// - extra-legend-separation (length): Extra space between legend and chart +/// -> content +#let area-chart( + data, + width: auto, + height: auto, + title: none, + show-line: true, + show-points: false, + fill-opacity: 40%, + line-width: auto, + point-size: 6pt, + x-label: none, + y-label: none, + annotations: none, + show-ticks: false, + show-minor-grid: false, + theme: none, + extra-legend-separation: 0pt +) = context { + layout(size => { + validate-simple-data(data, "area-chart") + let t = _resolve-ctx(theme) + let line-width = if line-width == auto { t.stroke-thick } else { line-width } + let norm = normalize-data(data) + let (labels, values) = (norm.labels, norm.values) + let (width, height) = resolve-size(width, height, size, n: values.len(), theme: t) + let color = get-color(t, 0) + + cartesian-frame(width, height, t, + x: category-axis(labels, title: x-label, mode: "points", inset: _point-inset(show-points, point-size, line-width)), + y: value-axis(calc.min(0, ..values), calc.max(0, ..values), title: y-label), + baseline: (y: 0), annotations: annotations, + title: title, show-ticks: show-ticks, show-minor-grid: show-minor-grid, + extra-legend-separation: extra-legend-separation, + p => { + let (px, py) = (p.px, p.py) + let points = values.enumerate().map(((i, v)) => (px(i), py(v))) + let base = py(0) + + place(left + top, polygon( + fill: color.transparentize(100% - fill-opacity), stroke: none, + (points.first().at(0), base), ..points, (points.last().at(0), base), + )) + if show-line { draw-polyline(points, line-width + color) } + if show-points { + for pt in points { + place(left + top, dx: pt.at(0) - point-size / 2, dy: pt.at(1) - point-size / 2, + circle(radius: point-size / 2, fill: color, stroke: t.marker-stroke)) + } + } + }, + ) + }) +} + +/// Renders a stacked area chart with multiple series layered cumulatively. +/// +/// - data (dictionary): Dict with `labels` and `series` (each with `name` and `values`) +/// - width (length): Chart width +/// - height (length): Chart height +/// - title (none, content): Optional chart title +/// - show-lines (bool): Draw boundary lines between series +/// - fill-opacity (ratio): Opacity of each filled area +/// - line-width (auto, length): Stroke width of boundary lines; `auto` uses the theme's `stroke-mid` +/// - show-legend (bool): Show series legend +/// - x-label (none, content): X-axis title +/// - y-label (none, content): Y-axis title +/// - annotations (none, array): Optional annotation descriptors +/// - theme (none, dictionary): Theme overrides +/// - extra-legend-separation (length): Extra space between legend and chart +/// -> content +#let stacked-area-chart( + data, + width: auto, + height: auto, + title: none, + show-lines: true, + fill-opacity: 70%, + line-width: auto, + show-legend: true, + x-label: none, + y-label: none, + annotations: none, + theme: none, + extra-legend-separation: 0pt +) = context { + layout(size => { + validate-series-data(data, "stacked-area-chart") + let t = _resolve-ctx(theme) + let line-width = if line-width == auto { t.stroke-mid } else { line-width } + let (width, height) = resolve-size(width, height, size, n: data.labels.len(), theme: t) + let (labels, series) = (data.labels, data.series) + let n = labels.len() + + // cumulative.at(i).at(si): running total of series 0..si at label i + let cumulative = range(n).map(i => { + let (running, cum) = (0, ()) + for s in series { + running += s.values.at(i) + cum.push(running) + } + cum + }) + let all-totals = cumulative.flatten() + + cartesian-frame(width, height, t, + x: category-axis(labels, title: x-label, mode: "points", inset: _point-inset(false, 0pt, line-width)), + y: value-axis(calc.min(0, ..all-totals), calc.max(0, ..all-totals), title: y-label), + baseline: (y: 0), annotations: annotations, + title: title, extra-legend-separation: extra-legend-separation, + legend: draw-legend-auto(series.map(s => s.name), t, show-legend: show-legend, fit-width: true, max-width: legend-max-width(width, t)), + p => { + let (px, py) = (p.px, p.py) + // Top series first, so the bottom series is drawn over its neighbour's edge + for si in range(series.len() - 1, -1, step: -1) { + let color = get-color(t, si) + let upper = range(n).map(i => (px(i), py(cumulative.at(i).at(si)))) + let lower = range(n - 1, -1, step: -1).map(i => (px(i), py(if si == 0 { 0 } else { cumulative.at(i).at(si - 1) }))) + place(left + top, polygon( + fill: color.transparentize(100% - fill-opacity), + stroke: if show-lines { color + line-width } else { none }, + ..upper, ..lower, + )) + } + }, + ) + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/bar.typ b/packages/preview/primaviz/0.12.0/src/charts/bar.typ new file mode 100644 index 0000000000..9fb3b8763b --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/bar.typ @@ -0,0 +1,368 @@ +// bar.typ - Bar charts (simple, horizontal, grouped, stacked, grouped-stacked) +#import "../theme.typ": _resolve-ctx, get-color +#import "../util.typ": normalize-data, normalize-errors +#import "../validate.typ": validate-simple-data, validate-series-data, validate-grouped-stacked-data +#import "../primitives/frame.typ": cartesian-frame +#import "../primitives/axes.typ": value-axis, category-axis, fit-label-size, value-text +#import "../primitives/legend.typ": draw-legend-auto, legend-max-width +#import "../primitives/polar.typ": separator-stroke +#import "../primitives/layout.typ": resolve-size, pin +#import "../primitives/row-chart.typ": row-chart + +// A vertical bar spanning the values v0..v1, `w` wide, centred on `cx`. +#let _vbar(py, cx, w, v0, v1, fill, stroke: none) = { + let (ya, yb) = (py(v0), py(v1)) + place(left + top, dx: cx - w / 2, dy: calc.min(ya, yb), + rect(width: w, height: calc.abs(ya - yb), fill: fill, stroke: stroke)) +} + +// Width of a bar in a group whose bars are `w` apart: the theme's gap between +// bars, but never more than a quarter of the slot, so dense groups keep their bars. +#let _grouped-width(t, w) = w - calc.min(t.bar-gap, w * 0.25) + +// A bar (or a group of bars) never grows past ten label heights: with one or +// two categories the slots are huge, and a bar as wide as the plot says nothing. +#let _slot-cap(t, w) = calc.min(w, 10 * t.axis-label-size) + +// The value axis of a bar chart: always includes the zero baseline. +// `pad` is the room value labels past negative bar ends need below the axis start. +#let _bar-axis(lo, hi, title, pad: 0pt) = { + let (lo, hi) = (calc.min(0, lo), calc.max(0, hi)) + value-axis(lo, if lo == hi { 1 } else { hi }, title: title, pad: (lo: if lo < 0 { pad } else { 0pt })) +} + +// Signed stacks: positives pile up from zero, negatives down from zero. +// Returns, per stack, the (series index, from, to) segments and the extent. +#let _stack-segments(values-by-series) = { + let (pos, neg) = (0, 0) + let segs = () + for (si, v) in values-by-series.enumerate() { + if v >= 0 { segs.push((si, pos, pos + v)); pos += v } else { segs.push((si, neg, neg + v)); neg += v } + } + (segs: segs, lo: neg, hi: pos) +} + +/// Renders a horizontal bar chart with category labels on the y-axis. +/// +/// Every row is labelled: with `height: auto` the chart grows with the number of +/// rows; with a fixed height the labels shrink to the theme's +/// `category-label-min-size` (5pt unless set) and are thinned only below that. +/// +/// - data (dictionary, array): Label-value pairs as dict or array of tuples +/// - width (length): Chart width +/// - height (auto, length): Chart height; `auto` gives every row room for its label +/// - bar-height (float): Bar thickness as fraction of slot (0 to 1) +/// - title (none, content): Optional chart title +/// - show-values (bool): Display value labels beside bars +/// - x-label (none, content): X-axis title +/// - y-label (none, content): Y-axis title +/// - annotations (none, array): Optional annotation descriptors +/// - theme (none, dictionary): Theme overrides +/// - extra-legend-separation (length): Extra space between legend and chart +/// -> content +#let horizontal-bar-chart( + data, + width: auto, + height: auto, + bar-height: 0.6, + title: none, + show-values: true, + x-label: none, + y-label: none, + annotations: none, + theme: none, + extra-legend-separation: 0pt +) = context { + layout(size => { + validate-simple-data(data, "horizontal-bar-chart") + let t = _resolve-ctx(theme) + let norm = normalize-data(data) + row-chart(norm.labels, norm.values, width, height, size, t, + title: title, x-label: x-label, y-label: y-label, annotations: annotations, + extra-legend-separation: extra-legend-separation, show-values: show-values, + (i, y, x-from, x-to, color, row-h) => { + let bh = _slot-cap(t, row-h * bar-height) + place(left + top, dx: calc.min(x-from, x-to), dy: y - bh / 2, + rect(width: calc.abs(x-to - x-from), height: bh, fill: color)) + }, + ) + }) +} + +/// Renders a vertical bar chart with one bar per category. +/// +/// - data (dictionary, array): Label-value pairs as dict or array of tuples +/// - width (length): Chart width +/// - height (length): Chart height +/// - bar-width (float): Bar width as fraction of slot (0 to 1) +/// - title (none, content): Optional chart title +/// - show-values (bool): Display value labels above bars +/// - x-label (none, content): X-axis title +/// - y-label (none, content): Y-axis title +/// - annotations (none, array): Optional annotation descriptors +/// - errors (none, array): Symmetric `(e1, e2, ...)` or asymmetric `((lo1, hi1), ...)` +/// - error-color (auto, color): Error bar color; `auto` uses the theme text color +/// - error-cap-width (auto, length): Width of the error bar caps; `auto` scales with bar width +/// - show-ticks (bool): Draw tick marks on the axis lines +/// - show-minor-grid (bool): Draw lighter minor grid lines between the major ones +/// - subtitle (none, content): Optional subtitle below the title +/// - radius (length): Corner radius of the chart container +/// - theme (none, dictionary): Theme overrides +/// - extra-legend-separation (length): Extra space between legend and chart +/// -> content +#let bar-chart( + data, + width: auto, + height: auto, + bar-width: 0.6, + title: none, + show-values: true, + x-label: none, + y-label: none, + errors: none, + error-color: auto, + error-cap-width: auto, + annotations: none, + show-ticks: false, + show-minor-grid: false, + subtitle: none, + radius: 0pt, + theme: none, + extra-legend-separation: 0pt +) = context { + layout(size => { + validate-simple-data(data, "bar-chart") + let t = _resolve-ctx(theme) + let norm = normalize-data(data) + let (labels, values) = (norm.labels, norm.values) + let n = values.len() + let (width, height) = resolve-size(width, height, size, n: n, theme: t) + let errs = normalize-errors(errors, n) + let strs = values.map(v => value-text(v, t)) + + // The axis covers the error bars too + let lo = calc.min(..values.enumerate().map(((i, v)) => v - if errs != none { errs.at(i).low } else { 0 })) + let hi = calc.max(..values.enumerate().map(((i, v)) => v + if errs != none { errs.at(i).high } else { 0 })) + + cartesian-frame(width, height, t, + x: category-axis(labels, title: x-label), + y: _bar-axis(lo, hi, y-label, pad: if show-values { 1.2 * t.value-label-size + t.label-offset } else { 0pt }), + baseline: (y: 0), annotations: annotations, + title: title, subtitle: subtitle, radius: radius, + show-ticks: show-ticks, show-minor-grid: show-minor-grid, + extra-legend-separation: extra-legend-separation, + reserve: (top: if show-values { t.value-label-size + t.label-offset } else { 0pt }), + p => { + let (px, py) = (p.px, p.py) + let bw = _slot-cap(t, p.X.pitch * bar-width) + let vsize = fit-label-size(strs, p.X.pitch, t.value-label-size) + let err-stroke = t.stroke-thin + if error-color != auto { error-color } else { t.text-color } + for (i, val) in values.enumerate() { + let cx = px(i) + _vbar(py, cx, bw, 0, val, get-color(t, i)) + + // Error bar: a vertical rule with a cap at each end + if errs != none { + let (y-low, y-high) = (py(val - errs.at(i).low), py(val + errs.at(i).high)) + let cap-w = if error-cap-width != auto { error-cap-width } else { bw * 0.4 } + place(left + top, line(start: (cx, y-low), end: (cx, y-high), stroke: err-stroke)) + for y in (y-low, y-high) { + place(left + top, line(start: (cx - cap-w / 2, y), end: (cx + cap-w / 2, y), stroke: err-stroke)) + } + } + + // Value label sits just beyond the bar end (and its error bar) + if show-values and vsize != none { + let above = val >= 0 + let reach = if errs != none { if above { errs.at(i).high } else { errs.at(i).low } } else { 0 } + let end = py(val + if above { reach } else { -reach }) + place(left + top, dx: cx - p.X.pitch / 2, dy: end + if above { -t.label-offset } else { t.label-offset }, + pin(width: p.X.pitch, align: center + if above { bottom } else { top }, + text(size: vsize, fill: t.text-color)[#strs.at(i)])) + } + } + }, + ) + }) +} + +/// Renders a grouped bar chart with multiple series side by side. +/// +/// - data (dictionary): Dict with `labels` and `series` (each with `name` and `values`) +/// - width (length): Chart width +/// - height (length): Chart height +/// - title (none, content): Optional chart title +/// - show-legend (bool): Show series legend +/// - x-label (none, content): X-axis title +/// - y-label (none, content): Y-axis title +/// - annotations (none, array): Optional annotation descriptors +/// - theme (none, dictionary): Theme overrides +/// - extra-legend-separation (length): Extra space between legend and chart +/// -> content +#let grouped-bar-chart( + data, + width: auto, + height: auto, + title: none, + show-legend: true, + x-label: none, + y-label: none, + annotations: none, + theme: none, + extra-legend-separation: 0pt +) = context { + layout(size => { + validate-series-data(data, "grouped-bar-chart") + let t = _resolve-ctx(theme) + let (labels, series) = (data.labels, data.series) + let n-series = series.len() + // Auto-width uses n_groups × n_series since each series gets its own bar column + let (width, height) = resolve-size(width, height, size, n: labels.len() * n-series, theme: t) + let all-values = series.map(s => s.values).flatten() + + cartesian-frame(width, height, t, + x: category-axis(labels, title: x-label), + y: _bar-axis(calc.min(..all-values), calc.max(..all-values), y-label), + baseline: (y: 0), annotations: annotations, + title: title, extra-legend-separation: extra-legend-separation, + legend: draw-legend-auto(series.map(s => s.name), t, show-legend: show-legend, fit-width: true, max-width: legend-max-width(width, t)), + p => { + let (px, py) = (p.px, p.py) + // The bars of a group share 80% of its slot, centred on it + let bw = _slot-cap(t, p.X.pitch * 0.8) / n-series + for gi in range(labels.len()) { + for (si, s) in series.enumerate() { + let cx = px(gi) + (si - (n-series - 1) / 2) * bw + _vbar(py, cx, _grouped-width(t, bw), 0, s.values.at(gi), get-color(t, si)) + } + } + }, + ) + }) +} + +/// Renders a stacked bar chart with series values stacked vertically. +/// +/// - data (dictionary): Dict with `labels` and `series` (each with `name` and `values`) +/// - width (length): Chart width +/// - height (length): Chart height +/// - title (none, content): Optional chart title +/// - show-legend (bool): Show series legend +/// - x-label (none, content): X-axis title +/// - y-label (none, content): Y-axis title +/// - annotations (none, array): Optional annotation descriptors +/// - theme (none, dictionary): Theme overrides +/// - extra-legend-separation (length): Extra space between legend and chart +/// -> content +#let stacked-bar-chart( + data, + width: auto, + height: auto, + title: none, + show-legend: true, + x-label: none, + y-label: none, + annotations: none, + theme: none, + extra-legend-separation: 0pt +) = context { + layout(size => { + validate-series-data(data, "stacked-bar-chart") + let t = _resolve-ctx(theme) + let (labels, series) = (data.labels, data.series) + let (width, height) = resolve-size(width, height, size, n: labels.len(), theme: t) + let stacks = range(labels.len()).map(i => _stack-segments(series.map(s => s.values.at(i)))) + + cartesian-frame(width, height, t, + x: category-axis(labels, title: x-label), + y: _bar-axis(calc.min(..stacks.map(s => s.lo)), calc.max(..stacks.map(s => s.hi)), y-label), + baseline: (y: 0), annotations: annotations, + title: title, extra-legend-separation: extra-legend-separation, + legend: draw-legend-auto(series.map(s => s.name), t, show-legend: show-legend, fit-width: true, max-width: legend-max-width(width, t)), + p => { + let (px, py) = (p.px, p.py) + for (i, stack) in stacks.enumerate() { + for (si, from, to) in stack.segs { + _vbar(py, px(i), _slot-cap(t, p.X.pitch * 0.6), from, to, get-color(t, si), stroke: separator-stroke(t, thickness: t.stroke-thin)) + } + } + }, + ) + }) +} + +/// Renders a grouped-stacked bar chart combining side-by-side groups with +/// stacked segments within each group. +/// +/// - data (dictionary): Dict with `labels` (x-axis categories) and `groups` +/// (array of dicts each with `name` and `segments`, where each segment has +/// `name` and `values`) +/// - width (length): Chart width +/// - height (length): Chart height +/// - title (none, content): Optional chart title +/// - show-legend (bool): Show segment legend +/// - x-label (none, content): X-axis title +/// - y-label (none, content): Y-axis title +/// - annotations (none, array): Optional annotation descriptors +/// - theme (none, dictionary): Theme overrides +/// - extra-legend-separation (length): Extra space between legend and chart +/// -> content +#let grouped-stacked-bar-chart( + data, + width: auto, + height: auto, + title: none, + show-legend: true, + x-label: none, + y-label: none, + annotations: none, + theme: none, + extra-legend-separation: 0pt +) = context { + layout(size => { + validate-grouped-stacked-data(data, "grouped-stacked-bar-chart") + let t = _resolve-ctx(theme) + let (width, height) = resolve-size(width, height, size, n: data.labels.len(), theme: t) + let (labels, groups) = (data.labels, data.groups) + let n-groups = groups.len() + + // Guard: nothing to draw + if labels.len() == 0 or n-groups == 0 { return } + + // Unique segment names (first-seen order) give the legend and the colors + let segment-names = () + for g in groups { + for seg in g.segments { + if seg.name not in segment-names { segment-names.push(seg.name) } + } + } + + // One signed stack per (label, group); names may be content, so look colors up by position + let stacks = range(labels.len()).map(li => groups.map(g => { + let st = _stack-segments(g.segments.map(seg => seg.values.at(li))) + (..st, segs: st.segs.zip(g.segments).map(((s, seg)) => (segment-names.position(nm => nm == seg.name), s.at(1), s.at(2)))) + })) + let all = stacks.flatten() + + cartesian-frame(width, height, t, + x: category-axis(labels, title: x-label), + y: _bar-axis(calc.min(..all.map(s => s.lo)), calc.max(..all.map(s => s.hi)), y-label), + baseline: (y: 0), annotations: annotations, + title: title, extra-legend-separation: extra-legend-separation, + legend: draw-legend-auto(segment-names, t, show-legend: show-legend, fit-width: true, max-width: legend-max-width(width, t)), + p => { + let (px, py) = (p.px, p.py) + // The groups of a label share 85% of its slot, centred on it + let gw = _slot-cap(t, p.X.pitch * 0.85) / n-groups + for (li, row) in stacks.enumerate() { + for (gi, stack) in row.enumerate() { + for (ci, from, to) in stack.segs { + _vbar(py, px(li) + (gi - (n-groups - 1) / 2) * gw, _grouped-width(t, gw), from, to, get-color(t, ci), + stroke: separator-stroke(t, thickness: t.stroke-thin)) + } + } + } + }, + ) + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/boxplot.typ b/packages/preview/primaviz/0.12.0/src/charts/boxplot.typ new file mode 100644 index 0000000000..1cd90db45d --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/boxplot.typ @@ -0,0 +1,223 @@ +// boxplot.typ - Box-and-whisker plot +#import "../theme.typ": _resolve-ctx, get-color +#import "../validate.typ": validate-boxplot-data +#import "../primitives/frame.typ": cartesian-frame +#import "../primitives/axes.typ": value-axis, category-axis, value-text, compact-text +#import "../primitives/layout.typ": resolve-size, spread-labels + +/// Renders a box-and-whisker plot for comparing distributions. +/// +/// - data (dictionary): Dict with `labels` and `boxes` (each with `min`, `q1`, `median`, `q3`, `max`, +/// and optional `outliers` array of values outside the whiskers) +/// - width (length): Chart width +/// - height (length): Chart height +/// - title (none, content): Optional chart title +/// - box-width (float): Box width as fraction of slot (0 to 1) +/// - show-values (bool): Display five-number summary labels beside each box. Where boxes sit +/// too close for all five, the labels shrink (to a legible floor), then thin out to the +/// median and extremes, then to the median alone, then none: they never touch a box or each other +/// - show-grid (auto, bool): Draw background grid lines; `auto` uses theme default +/// - stroke-width (auto, length): Stroke width for box outlines and whiskers; `auto` uses the theme's `stroke-mid` +/// - outlier-radius (length): Radius of outlier marker dots +/// - include-zero (bool): Extend the value axis to include zero; off by default so the axis fits the data +/// - x-label (none, content): X-axis title +/// - y-label (none, content): Y-axis title +/// - annotations (none, array): Optional annotation descriptors (see annotations.typ) +/// - theme (none, dictionary): Theme overrides +/// -> content +#let box-plot( + data, + width: auto, + height: auto, + title: none, + box-width: 0.5, + show-values: false, + show-grid: auto, + stroke-width: auto, + outlier-radius: 2pt, + include-zero: false, + x-label: none, + y-label: none, + annotations: none, + theme: none, +) = context { + layout(size => { + validate-boxplot-data(data, "box-plot") + let grid-overrides = if show-grid != auto { (show-grid: show-grid) } else { none } + let t = _resolve-ctx(theme, overrides: grid-overrides) + let stroke-width = if stroke-width == auto { t.stroke-mid } else { stroke-width } + let (width, height) = resolve-size(width, height, size, n: data.labels.len(), theme: t) + let (labels, boxes) = (data.labels, data.boxes) + + // The axis covers every whisker and outlier. Box plots compare spreads, so by + // default it fits the data rather than stretching down to a zero baseline. + let extremes = boxes.map(b => (b.min, b.max, ..b.at("outliers", default: ()))).flatten() + let (lo, hi) = (calc.min(..extremes), calc.max(..extremes)) + let (lo, hi) = if include-zero { (calc.min(0, lo), calc.max(0, hi)) } else { (lo, hi) } + + cartesian-frame(width, height, t, + x: category-axis(labels, title: x-label), + y: value-axis(lo, hi, title: y-label), + title: title, annotations: annotations, + p => { + let (px, py) = (p.px, p.py) + let half-box = p.X.pitch * box-width / 2 + let half-cap = half-box / 2 + for (i, b) in boxes.enumerate() { + let cx = px(i) + let color = get-color(t, i) + let whisker = stroke-width + color + let (y-min, y-q1, y-med, y-q3, y-max) = (b.min, b.q1, b.median, b.q3, b.max).map(py) + + // Whiskers (min to q1, q3 to max) with a cap at each end + place(left + top, line(start: (cx, y-min), end: (cx, y-q1), stroke: whisker)) + place(left + top, line(start: (cx, y-q3), end: (cx, y-max), stroke: whisker)) + for y in (y-min, y-max) { + place(left + top, line(start: (cx - half-cap, y), end: (cx + half-cap, y), stroke: whisker)) + } + + // Box from q1 to q3, with the median across it + place(left + top, dx: cx - half-box, dy: y-q3, + rect(width: half-box * 2, height: y-q1 - y-q3, fill: color.transparentize(20%), stroke: whisker)) + place(left + top, line(start: (cx - half-box, y-med), end: (cx + half-box, y-med), + stroke: stroke-width * 2 + t.text-color-inverse)) + + for o in b.at("outliers", default: ()) { + place(left + top, dx: cx - outlier-radius, dy: py(o) - outlier-radius, + circle(radius: outlier-radius, fill: color, stroke: none)) + } + } + + // Five-number summary next to each box. What is shown is the richest set that fits + // at a legible size: all five values, else the median and the extremes, else the + // median, else the extremes alone, else the maxima. Each set is tried in the values as given, then + // in 3 and in 2 significant digits (`15.35` -> `15.3` -> `15`, `85200` -> `85.2k` + // -> `85k`). Values sit beside their box, on the side that is free; a maximum or + // minimum with no room beside its box goes above or below the whisker instead. The + // type is as large as the room allows (down to a floor), and every label is checked + // against every box, whisker, outlier and other label, so none touches another. Only + // when not even the extremes fit on every box are they shown on every second (third) + // box, which have room for two (three) pitches. + if show-values { + let base = t.value-label-size + let floor = calc.min(5.5pt, base * 0.8) + let sums = boxes.map(b => (b.max, b.q3, b.median, b.q1, b.min)) + let n = boxes.len() + let pitch = p.X.pitch + let hit(a, b) = a.x < b.x + b.w and b.x < a.x + a.w and a.y < b.y + b.h and b.y < a.y + a.h + let sw = stroke-width + + // texts.at(level).at(box).at(kind), and their widths at the base size + let texts = (0, 1, 2).map(level => sums.map(vals => vals.map(v => { + if level > 0 { compact-text(v, t, sig: 4 - level) } else { + let tv = value-text(v, t) + if type(tv) == int or type(tv) == float { str(tv) } else { tv } + } + }))) + let widths = texts.map(lv => lv.map(row => row.map(tx => measure(text(size: base)[#tx]).width))) + let widest(level, kinds) = kinds.map(k => range(n).map(i => widths.at(level).at(i).at(k)).fold(0pt, calc.max)).fold(0pt, calc.max) + + // What labels must stay clear of, box by box: the box, whiskers with caps, outliers + let solids = boxes.enumerate().map(((i, b)) => { + let cx = px(i) + let out = ((x: cx - half-box, y: py(b.q3), w: half-box * 2, h: py(b.q1) - py(b.q3)), + (x: cx - sw / 2, y: py(b.max), w: sw, h: py(b.min) - py(b.max))) + for y in (py(b.min), py(b.max)) { out.push((x: cx - half-cap, y: y - sw / 2, w: half-cap * 2, h: sw)) } + for o in b.at("outliers", default: ()) { + out.push((x: cx - outlier-radius, y: py(o) - outlier-radius, w: outlier-radius * 2, h: outlier-radius * 2)) + } + out + }) + + // Tries to place the labels of `kinds` in `level` text at `size`, on every + // `stride`-th box + let attempt(kinds, level, size, stride) = { + let lbl-h = size * 1.1 + let ratio = size / base + // Beside a box, the values of that box keep clear of each other + let ys = sums.map(vals => { + let ks = kinds.sorted() + let spread = spread-labels(ks.map(k => py(vals.at(k))), gap: lbl-h, lo: p.y0, hi: p.y1) + let out = (:) + for (j, k) in ks.enumerate() { out.insert(str(k), spread.at(j)) } + out + }) + let placed = range(n).map(_ => ()) + let items = () + for k in kinds { + for i in range(0, n, step: stride) { + let w = widths.at(level).at(i).at(k) * ratio + let cx = px(i) + let v-edge = if k == 0 { py(boxes.at(i).max) - sw / 2 - 1.2pt - lbl-h / 2 } else { py(boxes.at(i).min) + sw / 2 + 1.2pt + lbl-h / 2 } + // Beside on the right, then on the left, then (extremes) above or below the whisker + let spots = ( + (cx + half-box + 2.5pt, ys.at(i).at(str(k)), "beside"), + (cx - half-box - 2.5pt - w, ys.at(i).at(str(k)), "beside"), + ) + if k == 0 or k == 4 { spots.push((cx - w / 2, v-edge, if k == 0 { "above" } else { "below" })) } + for (x0, yc, pos) in spots { + if yc == none { continue } // no room beside the box for this value + // above or below a whisker the label may slide to stay inside the plot + let x = if pos == "beside" { x0 } else { calc.min(calc.max(x0, p.x0 + 1.5pt), width - w - 1.5pt) } + let r = (x: x - 0.5pt, y: yc - lbl-h / 2, w: w + 1pt, h: lbl-h) + if r.x < (if pos == "beside" { p.x0 } else { 0pt }) or r.x + r.w > width { continue } + if r.y < 0pt or (pos == "below" and r.y + r.h > p.y1) { continue } + let near = range(calc.max(i - 1, 0), calc.min(i + 2, n)) + if near.any(j => solids.at(j).any(o => hit(o, r)) or placed.at(j).any(o => hit(o, r))) { continue } + placed.at(i).push(r) + items.push((x: x, y: yc, txt: texts.at(level).at(i).at(k), size: size)) + break + } + } + } + (placed: items.len(), total: kinds.len() * calc.ceil(n / stride), items: items) + } + + // Sets in order of richness. Within a set the values as given win unless a shorter + // form lets the type grow by a quarter or more; a set whose labels mostly (85%) find + // a place is taken, else the set that placed the largest share is drawn + let search(stride) = { + let best = none + for kinds in ((2, 0, 4, 1, 3), (2, 0, 4), (2,), (0, 4), (0,)) { + if best != none and best.placed >= 0.85 * best.total { continue } + let pick = none + let side = kinds.filter(k => k in (1, 2, 3)) + for level in (0, 1, 2) { + if level > 0 and texts.at(level) == texts.at(level - 1) { continue } + // The type size the room gives this set: beside the boxes for the inner values, + // beside or above for extremes alone + let w-side = widest(level, kinds) + let gap = pitch * stride - half-box * 2 - 3.5pt + let room = if side.len() > 0 { gap } else { calc.max(gap, pitch * stride - 1pt) } + let size = if w-side > 0pt and room > 0pt { calc.min(base, base * (room / w-side)) } else { 0pt } + // (the extremes alone are the last resort: they may go a little below the floor) + if size < (if side.len() > 0 { floor } else { calc.min(5pt, floor) }) { continue } + let r = attempt(kinds, level, size, stride) + if r.placed < 0.85 * r.total { + if pick == none and (best == none or r.placed / r.total > best.placed / best.total) { best = r } + continue + } + if pick == none or size >= pick.size * 1.25 { pick = (..r, size: size) } + } + if pick != none { best = pick } + } + best + } + let best = search(1) + if best == none or best.placed == 0 { + for stride in (2, 3) { + let r = search(stride) + if r != none and r.placed > 0 { best = r; break } + } + } + if best != none { + for it in best.items { + place(left + top, dx: it.x, dy: it.y, + box(height: 0pt, align(horizon, text(size: it.size, fill: t.text-color)[#it.txt]))) + } + } + } + }, + ) + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/bullet.typ b/packages/preview/primaviz/0.12.0/src/charts/bullet.typ new file mode 100644 index 0000000000..d449ba037d --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/bullet.typ @@ -0,0 +1,202 @@ +// bullet.typ - Bullet chart (Stephen Few's design) +// A compact gauge replacement showing a quantitative measure against a target +// with qualitative ranges (poor/satisfactory/good). + +#import "../theme.typ": _resolve-ctx, get-color, _is-dark, _shade +#import "../util.typ": nonzero, nice-ticks +#import "../validate.typ": validate-bullet-data, validate-bullet-charts-data +#import "../primitives/container.typ": chart-container +#import "../primitives/layout.typ": resolve-size, text-width, fit-font-size +#import "../primitives/rows.typ": linear-x-ticks, draw-x-axis, x-axis-extent, tick-label-width + +// Qualitative band shades, darkest = poor, lightest = good +#let _range-fills(t) = if _is-dark(t) { + (_shade(t, 15%), _shade(t, 25%), _shade(t, 35%)) +} else { + (t.text-color-light.transparentize(50%), t.text-color-light.transparentize(70%), t.text-color-light.transparentize(85%)) +} + +// Width of the title column for a set of (title, label) pairs. Bullets that +// are stacked as separate calls should still line up, so the column has a +// steady default width (90pt, or 35% of a narrow chart) and grows only when a +// title, measured, would not fit it, up to 45% of the chart. 0 when nothing +// has a title. +#let _title-column(pairs, width, t) = { + let gap = t.axis-label-gap * 1.5 + let natural = pairs.fold(0pt, (acc, p) => { + let (title, label) = p + let w = if title != none { text-width(title, t.axis-title-size, weight: "bold") } else { 0pt } + let lw = if label != none { text-width(label, t.axis-label-size) } else { 0pt } + calc.max(acc, w, lw) + }) + if natural == 0pt { 0pt } else { + calc.min(calc.max(natural + gap, calc.min(90pt, width * 0.35)), width * 0.45) + } +} + +// One bullet: title column, bands, value bar, target marker, scale. The box is +// `height` of bar plus the scale below it, plus a trailing gap so a stack of +// bullets reads as rows and each scale stays with its own bar. +#let _bullet(value, target, ranges, width, height, title, label, show-target, t, title-w) = { + let bar-color = get-color(t, 0) + let range-fills = _range-fills(t) + let max-range = nonzero(ranges.last()) + let gap = t.axis-label-gap * 1.5 + + let nt = nice-ticks(0, max-range, count: t.tick-count) + let tick-half = tick-label-width(linear-x-ticks(nt, 0, max-range, 0pt, 1pt, t), t) / 2 + let x0 = title-w + let bar-width = width - title-w - tick-half + let scale-h = x-axis-extent(t, marks: true) + + box(width: width, height: height + scale-h + t.axis-label-gap)[ + // Title column, centred on the bar + #if title != none { + let avail = title-w - gap + let title-size = fit-font-size(title, avail, t.axis-title-size, min: 7pt, weight: "bold") + let label-size = if label != none { fit-font-size(label, avail, t.axis-label-size, min: 6pt) } else { t.axis-label-size } + place(left + top, box(width: avail, height: height, align(left + horizon, { + text(size: title-size, weight: "bold", fill: t.text-color)[#title] + if label != none { + linebreak() + text(size: label-size, fill: t.text-color-light)[#label] + } + }))) + } + + // Qualitative ranges, widest first so the narrower ones sit on top + #for k in (2, 1, 0) { + place(left + top, dx: x0, rect( + width: bar-width * (ranges.at(k) / max-range), + height: height, + fill: range-fills.at(k), + stroke: none, + )) + } + + // Value bar (thinner, ~60% height, centred vertically) + #let bar-h = height * 0.6 + #place(left + top, dx: x0, dy: (height - bar-h) / 2, rect( + width: bar-width * calc.min(1, calc.max(0, value / max-range)), + height: bar-h, + fill: bar-color, + stroke: none, + )) + + // Target marker (short vertical line) + #if show-target { + let target-x = x0 + bar-width * calc.min(1, calc.max(0, target / max-range)) + let marker-h = height * 0.85 + place(left + top, dx: target-x - 1pt, dy: (height - marker-h) / 2, + rect(width: 2.5pt, height: marker-h, fill: t.text-color, stroke: none)) + } + + // Scale below the bar + #draw-x-axis(linear-x-ticks(nt, 0, max-range, x0, x0 + bar-width, t), height, t, + axis-line: false, marks: true) + ] +} + +/// Renders a single bullet chart — a horizontal bar showing an actual value +/// against a target marker, overlaid on qualitative range bands. +/// +/// - value (int, float): The actual measured value +/// - target (int, float): The target/comparative value shown as a vertical marker +/// - ranges (array): Three ascending thresholds defining poor, satisfactory, and good zones +/// - width (length): Chart width +/// - height (auto, length): Chart height (bar thickness); `auto` scales with the theme's `base-size` (3.75x, 30pt in the default theme) +/// - title (none, content, str): Optional label shown to the left +/// - label (none, content, str): Optional secondary label below the title +/// - show-target (bool): Whether to draw the target marker +/// - theme (none, dictionary): Theme overrides +/// -> content +#let bullet-chart( + value, + target, + ranges, + width: 250pt, + height: auto, + title: none, + label: none, + show-target: true, + theme: none, +) = context { + layout(size => { + validate-bullet-data((value: value, target: target, ranges: ranges), "bullet-chart") + let t = _resolve-ctx(theme) + let height = if height == auto { 3.75 * t.base-size } else { height } + set text(size: 11pt) // sized by the theme, not the document font + let (width, height) = resolve-size(width, height, size, container: false) + let title-w = if title != none { _title-column(((title, label),), width, t) } else { 0pt } + _bullet(value, target, ranges, width, height, title, label, show-target, t, title-w) + }) +} + +/// Renders multiple bullet charts stacked vertically for KPI dashboards. +/// +/// The bars share one title column (the widest title, measured), so they +/// start at the same x. +/// +/// - data (dictionary): Must contain a `bullets` key with an array of bullet specs. +/// Each spec: `(value: ..., target: ..., ranges: ..., title: "...")`. +/// Optional per-bullet keys: `label`. +/// - width (length): Overall width for each bullet +/// - bar-height (auto, length): Height of each individual bullet bar; `auto` scales with the theme's `base-size` (3.125x, 25pt in the default theme) +/// - gap (auto, length): Vertical spacing between bullets; `auto` scales with the theme's `base-size` (1.875x, 15pt in the default theme) +/// - title (none, content, str): Optional heading above all bullets +/// - theme (none, dictionary): Theme overrides +/// -> content +#let bullet-charts( + data, + width: 300pt, + bar-height: auto, + gap: auto, + title: none, + theme: none, +) = context { + layout(size => { + validate-bullet-charts-data(data, "bullet-charts") + let t = _resolve-ctx(theme) + let bar-height = if bar-height == auto { 3.125 * t.base-size } else { bar-height } + let gap = if gap == auto { 1.875 * t.base-size } else { gap } + set text(size: 11pt) // sized by the theme, not the document font + let width = resolve-size(width, 0pt, size, container: false).width + let bullets = data.bullets + let n = bullets.len() + + let bar-color = get-color(t, 0) + let range-fills = _range-fills(t) + let title-w = _title-column( + bullets.map(b => (b.at("title", default: none), b.at("label", default: none))), width, t) + + let key-size = t.axis-label-size + let key(mark, name) = box(inset: (right: t.legend-gap), { + box(baseline: 1.5pt, mark) + h(3pt) + text(size: key-size, fill: t.text-color)[#name] + }) + + box(width: width)[ + #if title != none { + align(center, text(size: t.title-size, weight: t.title-weight, fill: t.text-color)[#title]) + v(t.title-gap) + } + + #for (i, b) in bullets.enumerate() { + _bullet(b.value, b.target, b.ranges, width, bar-height, b.at("title", default: none), + b.at("label", default: none), true, t, title-w) + if i < n - 1 { v(gap) } + } + + // Key to the marks + #v(t.legend-gap) + #align(center, { + key(rect(width: 12pt, height: 6pt, fill: bar-color, stroke: none), [Actual]) + key(rect(width: 2.5pt, height: 10pt, fill: t.text-color, stroke: none), [Target]) + key(rect(width: 10pt, height: 8pt, fill: range-fills.at(0), stroke: none), [Poor]) + key(rect(width: 10pt, height: 8pt, fill: range-fills.at(1), stroke: none), [Fair]) + key(rect(width: 10pt, height: 8pt, fill: range-fills.at(2), stroke: none), [Good]) + }) + ] + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/bump.typ b/packages/preview/primaviz/0.12.0/src/charts/bump.typ new file mode 100644 index 0000000000..0e9c9df8a9 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/bump.typ @@ -0,0 +1,130 @@ +// bump.typ - Bump chart (multi-period ranking chart) +#import "../theme.typ": _resolve-ctx, get-color +#import "../validate.typ": validate-series-data +#import "../primitives/frame.typ": cartesian-frame +#import "../primitives/axes.typ": value-axis, category-axis +#import "../primitives/legend.typ": draw-legend-auto, legend-max-width +#import "../primitives/layout.typ": resolve-size, spread-labels +#import "../primitives/paths.typ": draw-polyline + +/// Renders a bump chart showing how items change ranking over time periods. +/// +/// Rankings are displayed with rank 1 at the top and rank N at the bottom +/// (inverted Y-axis). Each series is drawn as a thick colored line with dots +/// at each time period, similar to F1 race position charts. The ranks sit +/// inside the plot with room above and below, clear of the period labels, and +/// there are no axis lines: the lines and their end labels are the data. Labels +/// of series that share a rank (or sit closer than a line of text) are moved +/// apart, in order, and joined to their dot by a short leader line. +/// +/// - data (dictionary): Dict with `labels` (time periods) and `series` +/// (each with `name` and `values` representing rankings per period) +/// - width (length): Chart width +/// - height (length): Chart height +/// - title (none, content): Optional chart title +/// - dot-size (length): Diameter of point markers at each period +/// - line-width (auto, length): Stroke width of ranking lines; `auto` uses the theme's `stroke-heavy` +/// - show-labels (bool): Show series name labels at start and end of lines +/// - show-legend (bool): Show series legend below the chart (redundant, so not drawn, while the lines carry their own labels) +/// - theme (none, dictionary): Theme overrides +/// - extra-legend-separation (length): Extra space between legend and chart +/// -> content +#let bump-chart( + data, + width: auto, + height: auto, + title: none, + dot-size: 5pt, + line-width: auto, + show-labels: true, + show-legend: true, + theme: none, + extra-legend-separation: 0pt +) = context { + layout(size => { + validate-series-data(data, "bump-chart") + let t = _resolve-ctx(theme) + let line-width = if line-width == auto { t.stroke-heavy } else { line-width } + let (width, height) = resolve-size(width, height, size, n: data.labels.len(), theme: t) + let (labels, series) = (data.labels, data.series) + let n = labels.len() + if n == 0 or series.len() == 0 { return } + + // Ranks run from the best to the worst rank in the data, best at the top. + // The value axis grows upward, so it holds the negated rank; ticks label it back. + let all-values = series.map(s => s.values).flatten() + let (best, worst) = (calc.min(..all-values), calc.max(..all-values)) + let ranks = range(int(calc.floor(best)), int(calc.ceil(worst)) + 1) + let rank-axis = ( + min: -ranks.last(), max: -ranks.first(), step: 1, digits: 0, + ticks: ranks.rev().map(r => -r), labels: ranks.rev().map(str), + ) + + // Room for a series name beside the first and last dots + let name-w = if show-labels { + calc.max(..series.map(s => measure(text(size: t.axis-label-size, weight: "bold")[#s.name]).width)) + } else { 0pt } + let marker = dot-size / 2 + line-width / 2 + let name-room = if show-labels { name-w + dot-size / 2 + 4pt } else { 0pt } + // Lines keep half a rank (at most 24pt) away from the plot's top and bottom + let inset-y = calc.min(24pt, calc.max(marker + 2pt, height / ranks.len() * 0.35)) + + cartesian-frame(width, height, t, + x: category-axis(labels, mode: "points", inset: marker + 2pt), + y: value-axis(rank-axis.min, rank-axis.max, ticks: rank-axis, inset: inset-y, labelled: not show-labels), + axis-lines: (x: false, y: false), + title: title, extra-legend-separation: extra-legend-separation, + legend: draw-legend-auto(series.map(s => s.name), t, show-legend: show-legend and not show-labels, swatch-type: "line", fit-width: true, max-width: legend-max-width(width, t)), + reserve: (left: calc.max(0pt, name-room - marker - 2pt), right: calc.max(0pt, name-room - marker - 2pt)), + p => { + let (px, py) = (p.px, p.py) + + // End labels: where each series ends, nudged apart so none touch (ties share a + // position). Labels of the first period and of the last are spread separately; the + // room grows (`extra`) so that every label has a place. + // The text shrinks (to a floor) when the series are more than the plot's height holds + let lbl-h0 = measure(text(size: t.axis-label-size, weight: "bold", top-edge: "ascender", bottom-edge: "descender")[Ag]).height + let lbl-size = calc.max( + calc.min(5.5pt, t.axis-label-size), + calc.min(t.axis-label-size, (p.y1 - p.y0) / calc.max(1, series.len() - 1) / (lbl-h0 / t.axis-label-size))) + let lbl-h = lbl-h0 * (lbl-size / t.axis-label-size) + let spread(ranks) = { + let need = (ranks.len() - 1) * lbl-h + let extra = calc.max(0pt, need - (p.y1 - p.y0)) / 2 + spread-labels(ranks.map(r => py(-r)), gap: lbl-h, lo: p.y0 - extra, hi: p.y1 + extra) + } + let first-ys = spread(series.map(s => s.values.first())) + let last-ys = spread(series.map(s => s.values.last())) + + for (si, s) in series.enumerate() { + let color = get-color(t, si) + let points = s.values.enumerate().map(((i, rank)) => (px(i), py(-rank))) + draw-polyline(points, line-width + color) + for pt in points { + place(left + top, dx: pt.at(0) - dot-size / 2, dy: pt.at(1) - dot-size / 2, + circle(radius: dot-size / 2, fill: color, stroke: t.marker-stroke)) + } + + // Name beside the first dot (left) and the last (right), on a leader when moved + if show-labels { + let name = text(size: lbl-size, fill: color, weight: "bold")[#s.name] + let (first, last) = (points.first(), points.last()) + let leader(pt, y, dir) = if calc.abs(y - pt.at(1)) > 1pt { + place(left + top, line( + start: (pt.at(0) + dir * (dot-size / 2 + 0.5pt), pt.at(1)), + end: (pt.at(0) + dir * (dot-size / 2 + 3.5pt), y), stroke: 0.6pt + color)) + } + leader(first, first-ys.at(si), -1) + place(left + top, dx: first.at(0) - dot-size / 2 - 4pt - name-w, dy: first-ys.at(si), + box(width: name-w, height: 0pt, align(right + horizon, name))) + if n > 1 { + leader(last, last-ys.at(si), 1) + place(left + top, dx: last.at(0) + dot-size / 2 + 4pt, dy: last-ys.at(si), + box(width: name-w, height: 0pt, align(left + horizon, name))) + } + } + } + }, + ) + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/chord.typ b/packages/preview/primaviz/0.12.0/src/charts/chord.typ new file mode 100644 index 0000000000..b817129be9 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/chord.typ @@ -0,0 +1,146 @@ +// chord.typ - Chord diagrams showing flows between entities +#import "../theme.typ": _resolve-ctx, get-color +#import "../validate.typ": validate-chord-data +#import "../primitives/container.typ": chart-container +#import "../primitives/polar.typ": arc-points, place-wedge, polar-pt, separator-stroke +#import "../primitives/polar-labels.typ": place-outside-labels, polar-fit-radius, wrap-label +#import "../primitives/layout.typ": resolve-size + +/// Renders a chord diagram showing relationships and flows between entities +/// arranged around a circular ring. +/// +/// Each entity is drawn as an arc whose length is proportional to its total +/// flow (sum of outgoing and incoming values). Curved bands (chords) connect +/// pairs of entities, with the band width at each end proportional to the +/// individual flow value. Chords are translucent tints of the source entity's +/// color; the largest are drawn first so small flows stay visible on top. +/// +/// - data (dictionary): Must contain `labels` (array of strings) and `matrix` +/// (square array of arrays — `matrix.at(i).at(j)` is the flow from entity +/// *i* to entity *j*). +/// - size (length): Diameter of the outer ring +/// - arc-width (length): Thickness of the outer entity arcs +/// - title (none, content): Optional chart title +/// - show-labels (bool): Display entity name labels outside the arcs +/// - gap (int, float): Degrees of gap between adjacent entity arcs +/// - theme (none, dictionary): Theme overrides +/// -> content +#let chord-diagram( + data, + size: 300pt, + arc-width: 15pt, + title: none, + show-labels: true, + gap: 2, + theme: none, +) = context { + layout(avail => { + validate-chord-data(data, "chord-diagram") + let t = _resolve-ctx(theme) + let size = resolve-size(size, size, avail).width + let labels = data.labels + let matrix = data.matrix + let n = labels.len() + let (cx, cy) = (size / 2, size / 2) + + // Total flow per entity: outgoing (row) plus incoming (column) + let totals = array.range(n).map(i => { + matrix.at(i).sum() + array.range(n).map(j => matrix.at(j).at(i)).sum() + }) + let grand-total = totals.sum() + + // Nothing to draw if all flows are zero + if grand-total == 0 { + return align(center, chart-container(size, size, title, t)[ + #box(width: size, height: size)[ + #place(center + horizon, text(size: t.axis-label-size, fill: t.text-color-light)[No flow data]) + ] + ]) + } + + // Each entity's arc: a proportional share of what the gaps leave of the circle + let arc-spans = totals.map(tot => (360 - gap * n) * tot / grand-total) + let arc-starts = { + let out = () + let cursor = 0.0 + for span in arc-spans { + out.push(cursor) + cursor += span + gap + } + out + } + + // Labels beside their arcs; the ring is as large as they leave room for + let label-size = if n > 12 { t.axis-label-size } else { t.legend-size } + let items = if show-labels { + array.range(n).filter(i => arc-spans.at(i) > 0).map(i => ( + angle: arc-starts.at(i) + arc-spans.at(i) / 2, + priority: arc-spans.at(i), + body: wrap-label(text(size: label-size, fill: t.text-color, top-edge: "cap-height", bottom-edge: "descender")[#labels.at(i)], + calc.max(60pt, size * 0.3), arc-starts.at(i) + arc-spans.at(i) / 2), + )) + } else { () } + let outer-r = if show-labels { polar-fit-radius(items, cx, cy, size, size, gap: 5pt) } else { size / 2 - 1pt } + let inner-r = outer-r - calc.min(arc-width, outer-r * 0.25) + + // Each chord takes its slice of both arcs; `cursor` is how much of an arc is used + let deg-per-unit = array.range(n).map(i => if totals.at(i) > 0 { arc-spans.at(i) / totals.at(i) } else { 0 }) + let cursor = array.range(n).map(_ => 0.0) + let chords = () + for i in array.range(n) { + for j in array.range(n) { + let val = matrix.at(i).at(j) + if val <= 0 { continue } + let src = (arc-starts.at(i) + cursor.at(i), arc-starts.at(i) + cursor.at(i) + val * deg-per-unit.at(i)) + cursor.at(i) += val * deg-per-unit.at(i) + let dst = (arc-starts.at(j) + cursor.at(j), arc-starts.at(j) + cursor.at(j) + val * deg-per-unit.at(j)) + cursor.at(j) += val * deg-per-unit.at(j) + chords.push((from: i, to: j, val: val, src: src, dst: dst)) + } + } + + // A chord: the source arc, a curve through the centre to the target arc, + // the target arc, and the curve back + let curve(from-deg, to-deg, via: (cx, cy)) = { + let (p0, p2) = (polar-pt(cx, cy, inner-r, from-deg), polar-pt(cx, cy, inner-r, to-deg)) + array.range(1, 21).map(s => { + let (f, inv) = (s / 20, 1 - s / 20) + (inv * inv * p0.at(0) + 2 * inv * f * via.at(0) + f * f * p2.at(0), + inv * inv * p0.at(1) + 2 * inv * f * via.at(1) + f * f * p2.at(1)) + }) + } + + // One entity's flow to itself: a petal hanging from its arc (the usual + // chord would fold flat onto itself) + let petal(c) = { + let mid = (c.src.at(0) + c.dst.at(1)) / 2 + (..arc-points(cx, cy, inner-r, c.src.at(0), c.dst.at(1), steps: 14), + ..curve(c.dst.at(1), c.src.at(0), via: polar-pt(cx, cy, inner-r * 0.3, mid))) + } + align(center, chart-container(size, size, title, t)[ + #box(width: size, height: size)[ + // Chords, largest first, behind the arcs: translucent tints of their + // source colour, so crossings stay readable and no outline is needed + #for c in chords.sorted(key: c => -c.val) { + place(left + top, polygon( + fill: get-color(t, c.from).transparentize(55%), + stroke: none, + ..if c.from == c.to { petal(c) } else { + (..arc-points(cx, cy, inner-r, c.src.at(0), c.src.at(1), steps: 10), + ..curve(c.src.at(1), c.dst.at(0)), + ..arc-points(cx, cy, inner-r, c.dst.at(0), c.dst.at(1), steps: 10), + ..curve(c.dst.at(1), c.src.at(0))) + }, + )) + } + // Entity arcs + #for i in array.range(n) { + place-wedge(cx, cy, inner-r, outer-r, arc-starts.at(i), arc-starts.at(i) + arc-spans.at(i), get-color(t, i), + stroke: separator-stroke(t, thickness: 0.75pt)) + } + #place-outside-labels(items, cx, cy, outer-r, (left: 0pt, right: size, top: 0pt, bottom: size), gap: 5pt, + leader-stroke: 0.5pt + t.text-color-light) + ] + ]) + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/dashboard.typ b/packages/preview/primaviz/0.12.0/src/charts/dashboard.typ new file mode 100644 index 0000000000..452961badb --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/dashboard.typ @@ -0,0 +1,201 @@ +// dashboard.typ - Dashboard layout primitives (card, compact-table, alert, badge, separator) +#import "../theme.typ": _resolve-ctx, get-color, _is-dark, _shade +#import "../primitives/polar-labels.typ": label-ink, legible +#import "../primitives/status.typ": status-colors + +/// Renders a themed card container with optional title and description. +/// +/// - title (none, content, str): Optional card title +/// - desc (none, content, str): Optional description shown beside the title +/// - width (length): Card width +/// - theme (none, dictionary): Theme overrides +/// - body (content): Card body content +/// -> content +#let card(title: none, desc: none, width: 100%, theme: none, body) = context { + let t = _resolve-ctx(theme) + let fill = if t.background != none { t.background } else { white } + let stroke = if t.border != none { t.border } else { 0.75pt + t.text-color-light } + let muted = t.text-color-light + + box( + width: width, + stroke: stroke, + inset: 0pt, + fill: fill, + radius: t.border-radius, + )[ + #let pad = t.axis-label-gap + #if title != none { + box(inset: (x: pad, top: pad, bottom: 0pt), width: 100%)[ + #text(size: t.axis-title-size, weight: "bold", fill: t.text-color, title) + #if desc != none { + h(pad) + text(size: t.axis-label-size, fill: muted, desc) + } + ] + } + #box(inset: (x: pad, top: pad * 0.8, bottom: pad), width: 100%, body) + ] +} + +/// Renders a compact data table with themed header row and alternating fills. +/// +/// Rows are separated by hairlines only (no column rules), the header text is +/// whichever of the theme's text colours reads on the header fill, and the +/// first column is as wide as its content unless `col-widths` says otherwise. +/// +/// - headers (array): Column header strings +/// - rows (array): Array of row arrays (each row is an array of cell strings); columns of numbers are right-aligned, others left +/// - highlight-col (none, int): Column index to render in bold with primary color +/// - col-widths (none, array): Custom column widths; defaults to the first column fitting its content, the others sharing the rest +/// - theme (none, dictionary): Theme overrides +/// -> content +#let compact-table(headers, rows, highlight-col: none, col-widths: none, theme: none) = context { + let t = _resolve-ctx(theme) + let fill = if t.background != none { t.background } else { white } + let alt-fill = if t.background != none { _shade(t, 5%) } else { t.text-color-light.transparentize(90%) } + let header-fill = t.palette.at(1, default: t.palette.at(0)) + let rule = if t.border != none { t.border } else { 0.4pt + t.text-color-light } + let highlight-color = legible(t.palette.at(0), fill) + + // Numbers line up on the right; words read from the left + let number = regex("^[+−\\-$€£]?[0-9][0-9.,]*\\s?[%kKMB]?$") + let numeric(col) = rows.all(r => { + let cell = r.at(col, default: "") + cell == "" or (type(cell) == str and cell.match(number) != none) + }) + let cols = if col-widths != none { col-widths } else { (auto, ..range(headers.len() - 1).map(_ => 1fr)) } + table( + columns: cols, + align: range(headers.len()).map(col => if col > 0 and numeric(col) { right } else { left }), + stroke: (x, y) => (bottom: rule), + inset: (x: t.axis-label-gap * 0.8, y: t.axis-label-gap * 0.6), + fill: (_, row) => if row == 0 { header-fill } else if calc.rem(row, 2) == 0 { alt-fill } else { fill }, + ..headers.map(h => text(size: t.axis-label-size, weight: "bold", fill: label-ink(header-fill, t), upper(h))), + ..rows.flatten().enumerate().map(((i, cell)) => { + let col = calc.rem(i, headers.len()) + text( + size: t.value-label-size, + weight: if highlight-col != none and col == highlight-col { "bold" } else { "regular" }, + fill: if highlight-col != none and col == highlight-col { highlight-color } else { t.text-color }, + cell, + ) + }), + ) +} + +/// Renders a themed alert block with left border accent, icon, and optional title. +/// +/// Colours carry the meaning in light and dark themes alike: info is the +/// theme's first palette colour, warning amber, error red, success green +/// (a theme can set `warning-color`, `negative-color`, `positive-color`). +/// +/// - body (content): Alert body content +/// - variant (str): One of `"info"`, `"warning"`, `"error"`, `"success"` +/// - title (none, content, str): Optional alert title +/// - theme (none, dictionary): Theme overrides +/// -> content +#let alert(body, variant: "info", title: none, theme: none) = context { + let t = _resolve-ctx(theme) + let sc = status-colors(t) + let variants = ( + info: (accent: sc.info, icon: "ℹ"), + warning: (accent: sc.warning, icon: "!"), + error: (accent: sc.negative, icon: "✕"), + success: (accent: sc.positive, icon: "✓"), + ) + let vr = variants.at(variant, default: variants.info) + let accent = vr.accent + let bg = if _is-dark(t) { accent.darken(80%) } else { accent.lighten(90%) } + + box( + width: 100%, + fill: bg, + stroke: (left: 2.5pt + accent), + inset: (x: t.axis-label-gap, y: t.axis-label-gap), + )[ + #if title != none { + text(size: t.axis-title-size, weight: "bold", fill: legible(accent, bg, min-ratio: 4.5))[#vr.icon #h(3pt) #title] + v(2pt) + } + #text(size: t.value-label-size, fill: t.text-color, body) + ] +} + +/// Renders an inline colored pill badge. +/// +/// - label (str, content): Badge text +/// - variant (str): One of `"default"`, `"secondary"`, `"destructive"`, `"outline"`, `"success"` +/// - theme (none, dictionary): Theme overrides +/// -> content +#let badge(label, variant: "default", theme: none) = context { + let t = _resolve-ctx(theme) + let pal = t.palette + let primary = pal.at(0, default: blue) + let secondary = pal.at(1, default: gray) + let destructive = pal.at(2, default: red) + let success = if pal.len() > 3 { pal.at(3) } else { primary } + let fill-bg = if t.background != none { t.background } else { white } + + // Text on a coloured badge is whichever colour reads on it + let styles = ( + default: (bg: primary, fg: label-ink(primary, t)), + secondary: (bg: secondary, fg: label-ink(secondary, t)), + destructive: (bg: destructive, fg: label-ink(destructive, t)), + outline: (bg: fill-bg, fg: t.text-color), + success: (bg: success, fg: label-ink(success, t)), + ) + let s = styles.at(variant, default: styles.default) + box( + fill: s.bg, + stroke: if variant == "outline" { if t.border != none { t.border } else { 0.5pt + t.text-color-light } } else { none }, + inset: (x: t.axis-label-gap * 0.7, y: t.axis-label-gap * 0.25), + radius: t.border-radius, + // (the theme's smallest type: a badge reads as text, not as a mark) + text(size: t.axis-label-size, weight: "bold", fill: s.fg, upper(label)), + ) +} + +/// Renders a themed horizontal rule separator. +/// +/// - thickness (length): Line thickness +/// - theme (none, dictionary): Theme overrides +/// -> content +#let separator(thickness: 0.75pt, theme: none) = context { + let t = _resolve-ctx(theme) + let color = if t.border != none { + let b = t.border + if type(b) == color { b } + else if type(b) == stroke { b.paint } + else { t.text-color-light } + } else { t.text-color-light } + v(4pt) + line(length: 100%, stroke: thickness + color) + v(4pt) +} + +/// Lays out content blocks in a dashboard grid. +/// +/// - cols (int, array): Number of equal columns, or array of column widths (e.g., `(2fr, 1fr)`) +/// - rows (array): Array of arrays, where each inner array contains the content blocks for that row. +/// Example: `((card1, card2), (card3,))` creates 2 rows. +/// - gap (length): Gap between cells +/// - row-gap (none, length): Vertical gap between rows; defaults to `gap` if none +/// -> content +#let dashboard-layout(rows, cols: 2, gap: 6pt, row-gap: none) = { + let rg = if row-gap != none { row-gap } else { gap } + let col-spec = if type(cols) == int { + range(cols).map(_ => 1fr) + } else { + cols + } + + for (i, row) in rows.enumerate() { + if i > 0 { v(rg) } + grid( + columns: col-spec, + column-gutter: gap, + ..row + ) + } +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/diverging.typ b/packages/preview/primaviz/0.12.0/src/charts/diverging.typ new file mode 100644 index 0000000000..49fd224b75 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/diverging.typ @@ -0,0 +1,152 @@ +// diverging.typ - Diverging bar chart (bars extend left/right from center axis) +#import "../theme.typ": _resolve-ctx, get-color +#import "../util.typ": nonzero, nice-ticks, contrast-text +#import "../validate.typ": validate-diverging-data +#import "../primitives/container.typ": chart-container +#import "../primitives/layout.typ": resolve-size, text-width, text-height, fit-font-size, max-text-width +#import "../primitives/rows.typ": row-layout, row-y, end-label, row-value-size, value-str, linear-x-ticks, draw-x-axis, x-axis-extent, tick-label-width + +/// Renders a horizontal diverging bar chart where bars extend left and right +/// from a central vertical axis. Useful for survey results (agree/disagree), +/// sentiment analysis, and population pyramids. +/// +/// The two sides are named directly above their halves of the chart (a legend +/// would only repeat that), when `left-label` / `right-label` are given. +/// +/// - data (dictionary): Dictionary with keys: +/// - `labels` (array): Category labels for each row +/// - `left-values` (array): Numeric values for the left side +/// - `right-values` (array): Numeric values for the right side +/// - `left-label` (string, optional): Name of the left side +/// - `right-label` (string, optional): Name of the right side +/// - width (length): Chart width +/// - height (length): Chart height +/// - title (none, content): Optional chart title +/// - show-values (bool): Display value labels at bar ends +/// - bar-height (auto, float): Bar thickness as fraction of slot (0 to 1), auto = 0.6 +/// - x-label (none, content): X-axis title +/// - theme (none, dictionary): Theme overrides +/// - extra-legend-separation (length): Extra space between title block and chart +/// -> content +#let diverging-bar-chart( + data, + width: auto, + height: auto, + title: none, + show-values: true, + bar-height: auto, + x-label: none, + theme: none, + extra-legend-separation: 0pt +) = context { + layout(size => { + validate-diverging-data(data, "diverging-bar-chart") + let t = _resolve-ctx(theme) + let labels = data.labels + let n = labels.len() + let (width, height) = resolve-size(width, height, size, n: n, theme: t, rows: true) + + let left-values = data.left-values.map(calc.abs) + let right-values = data.right-values.map(calc.abs) + let left-label = if "left-label" in data { data.left-label } else { none } + let right-label = if "right-label" in data { data.right-label } else { none } + let bar-frac = if bar-height == auto { 0.6 } else { bar-height } + + // Scale to the top tick (not the raw max) so bars and tick labels agree + let max-raw = calc.max(..left-values, ..right-values) + let nt = nice-ticks(0, nonzero(max-raw), count: t.tick-count) + let max-val = nt.max + + // Side names: direct labels in a band above the rows + let label-size = t.axis-label-size + let swatch = label-size * 0.8 + let has-names = (left-label != none or right-label != none) and t.legend-position != "none" + let header-h = if has-names { text-height("Mg", label-size) + t.axis-label-gap * 1.5 } else { 0pt } + let axis-h = x-axis-extent(t, title: x-label) + + let probe = row-layout(width, height, labels, t, pad-top: header-h, pad-bottom: axis-h) + let v-size = row-value-size(t, probe.row-h) + let show-values = show-values and v-size != none + let v-size = if v-size == none { 5pt } else { v-size } + let v-gap = t.axis-label-gap / 2 + let v-str(v) = value-str(v, t) + + // The axis is symmetric, so both sides keep the same margin: whatever the + // side with the longest labels needs beyond its longest bar + let ticks-probe = linear-x-ticks(nt, 0, max-val, 0pt, 1pt, t) + let tick-half = tick-label-width(ticks-probe, t) / 2 + let span = width - probe.label-w - probe.gap + let side-reserve(vals) = { + let f = calc.max(..vals) / max-val + if not show-values or f < 0.001 { 0pt } else { + let need = max-text-width(vals.map(v => v-str(v)), v-size) + v-gap + calc.max(0pt, (need - span / 2 * (1 - f)) / f) + } + } + let reserve = calc.min( + calc.max(tick-half, side-reserve(data.left-values), side-reserve(data.right-values)), + span * 0.2, + ) + let lay = row-layout(width, height, labels, t, pad-top: header-h, pad-bottom: axis-h, inset-left: reserve, inset-right: reserve) + let (x0, x1) = (lay.x0, lay.x1) + let cx = (x0 + x1) / 2 + let half = (x1 - x0) / 2 + let bar-h = lay.row-h * bar-frac + let left-color = get-color(t, 0) + let right-color = get-color(t, 1) + + // Mirrored ticks: the same ranks on both sides keep the same labels + let ticks = () + for (k, tk) in linear-x-ticks(nt, 0, max-val, cx, cx + half, t).enumerate() { + ticks.push((..tk, k: k)) + if tk.value > 0 { ticks.push((x: 2 * cx - tk.x, label: tk.label, value: -tk.value, k: k)) } + } + + chart-container(width, height, title, t, extra-legend-separation: extra-legend-separation)[ + #box(width: width, height: height)[ + #draw-x-axis(ticks, lay.y1, t, x0: x0, x1: x1, grid-top: lay.y0, title: x-label) + #place(left + top, line(start: (cx, lay.y0), end: (cx, lay.y1), stroke: t.axis-stroke)) + #lay.labels + + // Side names above their halves, keyed by the bar colour + #if has-names { + let name-y = lay.y0 - header-h / 2 - t.axis-label-gap / 4 + let avail = half - t.axis-label-gap - swatch - 4pt + // One size for both names: the smaller of what each needs + let sz = calc.min(..(left-label, right-label).filter(l => l != none) + .map(l => fit-font-size(l, avail, label-size, min: 6pt, weight: "bold"))) + if left-label != none { + let w = text-width(left-label, sz, weight: "bold") + place(left + top, dx: cx - t.axis-label-gap - w, dy: name-y, + box(height: 0pt, align(horizon, text(size: sz, weight: "bold", fill: t.text-color)[#left-label]))) + place(left + top, dx: cx - t.axis-label-gap - w - 4pt - swatch, dy: name-y - swatch / 2, + rect(width: swatch, height: swatch, fill: left-color, radius: 1pt)) + } + if right-label != none { + place(left + top, dx: cx + t.axis-label-gap, dy: name-y - swatch / 2, + rect(width: swatch, height: swatch, fill: right-color, radius: 1pt)) + place(left + top, dx: cx + t.axis-label-gap + swatch + 4pt, dy: name-y, + box(height: 0pt, align(horizon, text(size: sz, weight: "bold", fill: t.text-color)[#right-label]))) + } + } + + #for i in range(n) { + let y = row-y(lay, i) + let l-val = left-values.at(i) + let r-val = right-values.at(i) + let l-w = l-val / max-val * half + let r-w = r-val / max-val * half + place(left + top, dx: cx - l-w, dy: y - bar-h / 2, rect(width: l-w, height: bar-h, fill: left-color, stroke: none)) + place(left + top, dx: cx, dy: y - bar-h / 2, rect(width: r-w, height: bar-h, fill: right-color, stroke: none)) + if show-values { + let lo-x = lay.label-w + lay.gap / 2 + end-label(v-str(data.left-values.at(i)), cx - l-w, y, "left", v-size, t.text-color, + lo: lo-x, hi: width, gap: v-gap, mark-len: l-w, inside-fill: contrast-text(left-color, theme: t)) + end-label(v-str(data.right-values.at(i)), cx + r-w, y, "right", v-size, t.text-color, + lo: lo-x, hi: width, gap: v-gap, mark-len: r-w, inside-fill: contrast-text(right-color, theme: t)) + } + } + ] + ] + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/dual-axis.typ b/packages/preview/primaviz/0.12.0/src/charts/dual-axis.typ new file mode 100644 index 0000000000..d42ee86638 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/dual-axis.typ @@ -0,0 +1,85 @@ +// dual-axis.typ - Dual Y-axis line chart +#import "../theme.typ": _resolve-ctx, get-color +#import "../validate.typ": validate-dual-axis-data +#import "../primitives/frame.typ": cartesian-frame +#import "../primitives/axes.typ": value-axis, category-axis +#import "../primitives/legend.typ": draw-legend-auto, legend-max-width +#import "../primitives/layout.typ": resolve-size +#import "../primitives/paths.typ": draw-polyline + +/// Renders a line chart with two series on independent left and right y-axes. +/// +/// The two axes share one set of grid lines whenever their ranges allow it, so +/// every tick on the left lines up with a tick on the right. +/// +/// - data (dictionary): `labels`, `left` and `right` series (`name`, `values`) +/// - width (auto, length): Chart width +/// - height (auto, length): Chart height +/// - title (none, content): Optional chart title +/// - show-points (bool): Draw point markers +/// - line-width (auto, length): Stroke width of both lines; `auto` uses the theme's `stroke-thick` +/// - point-size (length): Diameter of point markers +/// - left-color (none, color): Left series color (theme palette when `none`) +/// - right-color (none, color): Right series color (theme palette when `none`) +/// - left-label (none, content): Left y-axis title +/// - right-label (none, content): Right y-axis title +/// - x-label (none, content): X-axis title +/// - show-grid (auto, bool): Override the theme's grid setting +/// - annotations (none, array): Annotation overlays +/// - theme (none, dictionary): Theme overrides +/// - extra-legend-separation (length): Extra space between legend and chart +/// -> content +#let dual-axis-chart( + data, + width: auto, + height: auto, + title: none, + show-points: true, + line-width: auto, + point-size: 6pt, + left-color: none, + right-color: none, + left-label: none, + right-label: none, + x-label: none, + show-grid: auto, + annotations: none, + theme: none, + extra-legend-separation: 0pt +) = context { + layout(size => { + validate-dual-axis-data(data, "dual-axis-chart") + let grid-overrides = if show-grid != auto { (show-grid: show-grid) } else { none } + let t = _resolve-ctx(theme, overrides: grid-overrides) + let line-width = if line-width == auto { t.stroke-thick } else { line-width } + let (width, height) = resolve-size(width, height, size, n: data.labels.len(), theme: t) + let (labels, ls, rs) = (data.labels, data.left, data.right) + + let l-color = if left-color != none { left-color } else { get-color(t, 0) } + let r-color = if right-color != none { right-color } else { get-color(t, 1) } + let inset = (if show-points { point-size } else { line-width }) / 2 + 1pt + + cartesian-frame(width, height, t, + x: category-axis(labels, title: x-label, mode: "points", inset: inset), + y: value-axis(calc.min(..ls.values), calc.max(..ls.values), title: left-label, color: l-color, inset: inset), + y2: value-axis(calc.min(..rs.values), calc.max(..rs.values), title: right-label, color: r-color, inset: inset), + title: title, extra-legend-separation: extra-legend-separation, annotations: annotations, + legend: draw-legend-auto( + ((name: ls.name, color: l-color), (name: rs.name, color: r-color)), + t, swatch-type: "line", fit-width: true, max-width: legend-max-width(width, t), + ), + p => { + for (series, color, pos) in ((ls, l-color, p.py), (rs, r-color, p.py2)) { + let points = series.values.enumerate().map(((i, v)) => ((p.px)(i), pos(v))) + draw-polyline(points, line-width + color) + if show-points { + for pt in points { + place(left + top, dx: pt.at(0) - point-size / 2, dy: pt.at(1) - point-size / 2, + circle(radius: point-size / 2, fill: color, stroke: t.marker-stroke)) + } + } + } + }, + ) + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/dumbbell.typ b/packages/preview/primaviz/0.12.0/src/charts/dumbbell.typ new file mode 100644 index 0000000000..bfede84852 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/dumbbell.typ @@ -0,0 +1,130 @@ +// dumbbell.typ - Dumbbell chart (before/after or range comparison) +#import "../theme.typ": _resolve-ctx, get-color +#import "../util.typ": nonzero, nice-ticks +#import "../validate.typ": validate-dumbbell-data +#import "../primitives/container.typ": chart-container +#import "../primitives/legend.typ": draw-legend-auto, legend-max-width +#import "../primitives/layout.typ": resolve-size, max-text-width +#import "../primitives/rows.typ": row-layout, row-y, end-reserves, end-label, row-value-size, value-str, linear-x-ticks, draw-x-axis, x-axis-extent, tick-label-width + +/// Renders a dumbbell chart showing range or before/after comparisons. +/// +/// Each category is drawn as a horizontal row with two dots (start and end +/// values) connected by a thin line. Categories appear on the Y-axis (left) +/// and values on the X-axis (bottom). +/// +/// - data (dictionary): Must contain `labels`, `start-values`, `end-values`, +/// and optionally `start-label` and `end-label`. +/// - width (length): Chart width +/// - height (length): Chart height +/// - title (none, content): Optional chart title +/// - dot-size (auto, length): Diameter of endpoint dots; `auto` scales with the theme's `base-size` (1.25x, 10pt in the default theme) +/// - line-width (auto, length): Stroke width of connecting lines; `auto` uses the theme's `stroke-thick` +/// - show-values (bool): Display numeric values next to dots +/// - theme (none, dictionary): Theme overrides +/// - extra-legend-separation (length): Extra space between legend and chart +/// -> content +#let dumbbell-chart( + data, + width: auto, + height: auto, + title: none, + dot-size: auto, + line-width: auto, + show-values: false, + theme: none, + extra-legend-separation: 0pt +) = context { + layout(size => { + validate-dumbbell-data(data, "dumbbell-chart") + let t = _resolve-ctx(theme) + let line-width = if line-width == auto { t.stroke-thick } else { line-width } + let labels = data.labels + let n = labels.len() + let (width, height) = resolve-size(width, height, size, n: n, theme: t, rows: true) + + let start-values = data.start-values + let end-values = data.end-values + let start-label = if "start-label" in data { data.start-label } else { "Start" } + let end-label-name = if "end-label" in data { data.end-label } else { "End" } + + // Nice step-aligned axis range + let all-values = start-values + end-values + let nt = nice-ticks(calc.min(..all-values), calc.max(..all-values), count: t.tick-count) + let (lo, hi) = (nt.min, nt.max) + let span = nonzero(hi - lo) + + // Rows: measured label column, with the axis band below + let axis-h = x-axis-extent(t) + let probe = row-layout(width, height, labels, t, pad-bottom: axis-h) + let dot-d = calc.min(if dot-size == auto { 1.25 * t.base-size } else { dot-size }, probe.row-h * 0.7) + let dot-r = dot-d / 2 + let v-size = row-value-size(t, probe.row-h) + let show-values = show-values and v-size != none + let v-size = if v-size == none { 5pt } else { v-size } + let v-gap = t.axis-label-gap / 2 + + // Room beside the plot ends for the dots, their value labels and the + // outermost tick labels, solved from where the extreme dots actually sit + let tick-half = tick-label-width(linear-x-ticks(nt, lo, hi, 0pt, 1pt, t), t) / 2 + let v-w = if show-values { + max-text-width(all-values.map(v => value-str(v, t)), v-size) + } else { 0pt } + let need = dot-r + if show-values { v-gap + v-w } else { 0pt } + let reserve = end-reserves( + width - probe.label-w - probe.gap, + (calc.min(..all-values) - lo) / span, need, + (hi - calc.max(..all-values)) / span, need, + floor: calc.max(dot-r, tick-half), + ) + let lay = row-layout(width, height, labels, t, pad-bottom: axis-h, inset-left: reserve.lo, inset-right: reserve.hi) + let (x0, x1) = (lay.x0, lay.x1) + let val-to-x(v) = x0 + (v - lo) / span * lay.plot-w + + let start-color = get-color(t, 0) + let end-color = get-color(t, 1) + + // The legend keys the marks, so its swatches are the dots' size (the theme's + // `legend-swatch-size`; `legend-swatch-size()` caps it at 3/4 of the legend text) + let legend-content = draw-legend-auto( + ((name: start-label, color: start-color), (name: end-label-name, color: end-color)), + (..t, legend-swatch-size: dot-d), swatch-type: "circle", fit-width: true, max-width: legend-max-width(width, t), + ) + + chart-container(width, height, title, t, legend: legend-content, extra-legend-separation: extra-legend-separation)[ + #box(width: width, height: height)[ + // Light row guides carry the eye from label to dots + #for i in range(n) { + place(left + top, line(start: (x0, row-y(lay, i)), end: (x1, row-y(lay, i)), stroke: t.grid-stroke)) + } + + #draw-x-axis(linear-x-ticks(nt, lo, hi, x0, x1, t), lay.y1, t, x0: x0, x1: x1, grid-top: lay.y0) + #lay.labels + + #for i in range(n) { + let y = row-y(lay, i) + let sv = start-values.at(i) + let ev = end-values.at(i) + let xs = val-to-x(sv) + let xe = val-to-x(ev) + + place(left + top, line(start: (xs, y), end: (xe, y), stroke: line-width + t.text-color-light)) + place(left + top, dx: xs - dot-r, dy: y - dot-r, circle(radius: dot-r, fill: start-color, stroke: t.marker-stroke)) + place(left + top, dx: xe - dot-r, dy: y - dot-r, circle(radius: dot-r, fill: end-color, stroke: t.marker-stroke)) + + // Value labels sit on the outer side of each dot: the lower value to the + // left, the higher to the right (equal values share one label) + if show-values { + let lo-x = lay.label-w + lay.gap / 2 + let (x-lo, x-hi) = if sv <= ev { (xs, xe) } else { (xe, xs) } + let (v-lo, v-hi) = if sv <= ev { (sv, ev) } else { (ev, sv) } + if v-lo != v-hi { + end-label(value-str(v-lo, t), x-lo - dot-r, y, "left", v-size, t.text-color, lo: lo-x, hi: width, gap: v-gap) + } + end-label(value-str(v-hi, t), x-hi + dot-r, y, "right", v-size, t.text-color, lo: lo-x, hi: width, gap: v-gap) + } + } + ] + ] + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/funnel.typ b/packages/preview/primaviz/0.12.0/src/charts/funnel.typ new file mode 100644 index 0000000000..34d5c2cd0c --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/funnel.typ @@ -0,0 +1,234 @@ +// funnel.typ - Funnel chart for process/conversion stages +#import "../theme.typ": _resolve-ctx, get-color +#import "../util.typ": normalize-data, nonzero, contrast-text +#import "../validate.typ": validate-simple-data +#import "../primitives/container.typ": chart-container +#import "../primitives/layout.typ": resolve-size, text-width, fit-font-size, spread-labels, place-centered +#import "../primitives/rows.typ": value-str + +/// Renders a funnel chart for visualizing process or conversion stages. +/// +/// Each stage's name (and, optionally, its value and share of the first +/// stage) is written inside its segment when it fits, in the segment's +/// contrast colour. Segments too narrow for their label are labelled in a +/// shared column to the right, joined by a thin horizontal leader. +/// +/// The outside column gets the room its labels *measure*: the funnel gives up +/// width (it narrows, and moves left of centre if it must) rather than wrap a +/// label into a sliver. Labels are two lines (name over detail) when they fit +/// the stage pitch, else one line, shrinking to a floor; only past that are +/// stages left unlabelled, spread evenly with the first and last kept. +/// +/// - data (dictionary, array): Label-value pairs as dict or array of tuples +/// - width (length): Chart width +/// - height (length): Chart height +/// - title (none, content): Optional chart title +/// - show-values (bool): Display numeric values on segments +/// - show-percentages (bool): Display conversion percentages relative to the first stage +/// - gap (auto, length): Vertical gap between segments; `auto` scales with the theme's `base-size` (0.375x, 3pt in the default theme) +/// - color-by-stage (bool): Give every stage its own palette colour. By default all stages share the first palette colour: the stage is named by its label and the share by its width, so a colour per stage would encode nothing +/// - theme (none, dictionary): Theme overrides +/// -> content +#let funnel-chart( + data, + width: auto, + height: auto, + title: none, + show-values: true, + show-percentages: true, + gap: auto, + color-by-stage: false, + theme: none, +) = context { + layout(size => { + validate-simple-data(data, "funnel-chart") + let t = _resolve-ctx(theme) + let gap = if gap == auto { 0.375 * t.base-size } else { gap } + let norm = normalize-data(data) + let labels = norm.labels + let values = norm.values + let n = values.len() + let (width, height) = resolve-size(width, height, size, n: n, theme: t, rows: true, row-pitch: t.cell-size * 1.5) + + let max-val = nonzero(calc.max(..values)) + let first-val = nonzero(values.at(0)) + let seg-h = (height - gap * (n - 1)) / n + let pitch = seg-h + gap + let mid-y(i) = i * pitch + seg-h / 2 + + // "value · share" under each name + let details = values.map(v => { + let parts = () + if show-values { parts.push(value-str(v, t)) } + if show-percentages { parts.push(str(calc.round(v / first-val * 100, digits: 1)) + "%") } + if parts.len() > 0 { parts.join(" · ") } else { "" } + }) + + let s0 = t.value-label-size + let detail-k = 0.85 // detail line size relative to the name + let lead-k = 0.3 // leading between the two lines, in em of the name + let pad = 4pt // clear space between text and the segment edge + let s-min = calc.min(s0, 8pt) // smallest size for a label set inside a segment + let s-out = calc.max(6pt, s0 * 0.9) // size of labels set outside + let s-floor = 6pt // ... and the least they shrink to + let lead = 12pt // clear space between the funnel and its label column + let col-cap = width * 0.45 // the label column never takes more than this + + // Segment geometry for a funnel whose widest stage has half width `hw`: + // half widths at the top and bottom edge of every segment + let geom(hw) = { + let half(v) = calc.max(0pt, v / max-val * hw) + ( + tops: values.map(half), + bots: values.enumerate().map(((i, v)) => if i < n - 1 { half(values.at(i + 1)) } else { half(v) }), + ) + } + + // Decide how each label is set: the largest size (down to `s-min`) at which + // the full label (name and detail) fits inside the trapezoid, measured at + // the text's lowest edge; otherwise outside. A stage never loses its value + // to the segment's size: the name alone is only set inside when the chart + // has no detail to show + let plan-for(g) = values.enumerate().map(((i, v)) => { + let name = labels.at(i) + let det = details.at(i) + // Usable width at height `y` below the segment's top + let width-at(y) = 2 * (g.tops.at(i) + (g.bots.at(i) - g.tops.at(i)) * (y / seg-h)) + let found = none + for k in range(int(calc.max(0pt, s0 - s-min) / 1pt) + 1) { + if found != none { continue } + let s = s0 - k * 1pt + let w-name = text-width(name, s, weight: "bold") + let w-det = if det != "" { text-width(det, s * detail-k) } else { 0pt } + let stack-h = s * (1.2 + detail-k * 1.2 + lead-k) + let line-h = s * 1.2 + if det != "" and stack-h <= seg-h - 2pt and calc.max(w-name, w-det) <= width-at(seg-h / 2 + stack-h / 2) - 2 * pad { + found = (mode: "stack", size: s) + } else if det != "" and line-h <= seg-h - 2pt and w-name + 0.4 * s + w-det <= width-at(seg-h / 2 + line-h / 2) - 2 * pad { + found = (mode: "inline", size: s) + } else if det == "" and line-h <= seg-h - 2pt and w-name <= width-at(seg-h / 2 + line-h / 2) - 2 * pad { + found = (mode: "name", size: s) + } + } + if found == none { (mode: "out", size: s-out) } else { found } + }) + let outs-of(pl) = array.range(n).filter(i => pl.at(i).mode == "out") + + // An outside label: name over detail ("stack") or "name detail" on one line + // ("inline"), wrapped at `w`; one word wider than `w` shrinks instead + let out-body(i, style, sz0, w) = { + let name = labels.at(i) + let det = details.at(i) + let words = if type(name) == str { name.split(" ") } else { (name,) } + let sz = calc.min(sz0, ..words.map(wd => fit-font-size(wd, w, sz0, min: s-floor, weight: "bold"))) + let name-t = text(size: sz, weight: "bold", fill: t.text-color)[#name] + if det == "" { name-t } else { + let det-t = text(size: sz * detail-k, fill: t.text-color-light)[#det] + if style == "inline" { [#name-t#h(0.5 * sz)#det-t] } else { + stack(dir: ttb, spacing: sz * lead-k, name-t, det-t) + } + } + } + + // One style for the whole column: two lines when they fit the stage pitch + // (or the column, when the labels are few), else one line, as large as the + // pitch allows + let out-style(outs) = { + let tallest(style, sz) = outs.map(i => measure(box(width: col-cap, out-body(i, style, sz, col-cap))).height) + .fold(0pt, (a, b) => calc.max(a, b)) + let h-stack = tallest("stack", s-out) + if outs.len() == 0 or h-stack + 0.3 * s-out <= pitch or outs.len() * (h-stack + 0.3 * s-out) <= height { + (style: "stack", size: s-out) + } else { + let found = none + for k in range(int((s-out - s-floor) / 0.5pt) + 1) { + if found != none { continue } + let s = s-out - k * 0.5pt + if tallest("inline", s) + 0.3 * s <= pitch { found = (style: "inline", size: s) } + } + if found == none { (style: "inline", size: s-floor) } else { found } + } + } + let out-width(outs, st) = calc.min(col-cap, + outs.map(i => measure(out-body(i, st.style, st.size, col-cap)).width).fold(0pt, (a, b) => calc.max(a, b))) + // How far out the stages that need labels reach, relative to the widest stage + let out-reach(outs) = calc.max(..outs.map(i => (values.at(i) + values.at(calc.min(i + 1, n - 1))) / 2 / max-val)) + + // Fit the funnel to its label column: start full width, find which labels + // go outside, give them the room they measure, and repeat (narrower funnel, + // more outside labels, wider column) until nothing changes + let hw = width / 2 + for _ in range(6) { + let outs = outs-of(plan-for(geom(hw))) + if outs.len() == 0 { break } + let lw = out-width(outs, out-style(outs)) + let hw2 = calc.min(width / 2, (width - lead - lw) / (1 + out-reach(outs))) + if hw2 >= hw - 0.25pt { break } + hw = hw2 + } + let g = geom(hw) + let (tops, bots) = (g.tops, g.bots) + let plan = plan-for(g) + let outs = outs-of(plan) + + // Funnel centre and the label column: the column starts just clear of the + // widest outside stage and runs to the right edge + let st = out-style(outs) + let reach = if outs.len() > 0 { out-reach(outs) } else { 0 } + let cx = if outs.len() == 0 { width / 2 } else { + calc.max(hw, calc.min(width / 2, width - lead - out-width(outs, st) - hw * reach)) + } + let out-x = cx + hw * reach + lead + let out-w = calc.max(24pt, width - out-x) + let out-bodies = outs.map(i => { + let b = out-body(i, st.style, st.size, out-w) + (body: b, h: measure(box(width: out-w, b)).height) + }) + + // The column is spread by the tallest label, with room for descenders; if + // they cannot all fit even so, as many as fit are kept, spread evenly (first + // and last kept), rather than let them overlap + let h-out = out-bodies.fold(0pt, (acc, b) => calc.max(acc, b.h)) + let desc = 0.3 * st.size + let out-ys = spread-labels(outs.map(mid-y), sizes: h-out, gap: desc, min-gap: desc, lo: 0pt, hi: height) + + chart-container(width, height, title, t)[ + #box(width: width, height: height)[ + #for (i, val) in values.enumerate() { + let y-top = i * pitch + let color = get-color(t, if color-by-stage { i } else { 0 }) + place(left + top, dy: y-top, polygon( + fill: color, + stroke: t.marker-stroke, + (cx - tops.at(i), 0pt), (cx + tops.at(i), 0pt), + (cx + bots.at(i), seg-h), (cx - bots.at(i), seg-h), + )) + + let p = plan.at(i) + let name = labels.at(i) + let det = details.at(i) + if p.mode != "out" { + let fg = contrast-text(color, theme: t) + let name-t = text(size: p.size, weight: "bold", fill: fg)[#name] + let det-t = text(size: p.size * detail-k, fill: fg)[#det] + let body = if p.mode == "stack" { + stack(dir: ttb, spacing: p.size * lead-k, name-t, det-t) + } else if p.mode == "inline" { + [#name-t#h(0.4 * p.size)#det-t] + } else { name-t } + place-centered(cx - tops.at(i), y-top + seg-h / 2, body, width: 2 * tops.at(i), align: center) + } + } + + // Outside labels: one column, joined to their stage by a leader + #for (j, i) in outs.enumerate() { + let ly = out-ys.at(j) + if ly == none { continue } + let edge = cx + (tops.at(i) + bots.at(i)) / 2 + 3pt + place(left + top, line(start: (edge, mid-y(i)), end: (out-x - 3pt, ly), stroke: t.stroke-thin + t.text-color-light)) + place-centered(out-x, ly, out-bodies.at(j).body, width: out-w, align: left) + } + ] + ] + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/gantt.typ b/packages/preview/primaviz/0.12.0/src/charts/gantt.typ new file mode 100644 index 0000000000..7ca533f62c --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/gantt.typ @@ -0,0 +1,166 @@ +// gantt.typ - Gantt chart (timeline bar chart for project scheduling) +#import "../theme.typ": _resolve-ctx, get-color +#import "../validate.typ": validate-gantt-data +#import "../primitives/container.typ": chart-container +#import "../primitives/layout.typ": resolve-size, text-width, text-height, label-stride, max-text-width, place-centered +#import "../primitives/legend.typ": draw-legend-auto, legend-max-width +#import "../primitives/rows.typ": row-layout, row-y, draw-x-axis, x-axis-extent + +/// Renders a Gantt chart — a timeline bar chart for project scheduling. +/// +/// Tasks are displayed as horizontal bars on a time axis. Tasks can be +/// optionally grouped for color coding. +/// +/// - data (dictionary): Must contain `tasks` array. Each task is a dict with +/// `name` (str), `start` (int), `end` (int), and optional `group` (str). +/// Optional `time-labels` array for custom x-axis labels. +/// - width (length): Chart width +/// - height (auto, length): Chart height. If `auto`, computed from task count. +/// - title (none, content): Optional chart title +/// - bar-height (auto, length): Height of each task bar; `auto` scales with the theme's `base-size` (1.75x, 14pt in the default theme) +/// - gap (auto, length): Vertical gap between task bars; `auto` scales with the theme's `base-size` (0.5x, 4pt in the default theme) +/// - show-grid (bool): Whether to draw vertical grid lines +/// - x-label (none, content): X-axis title +/// - show-legend (bool): Name the task groups and their colors: beside the first bar of each +/// group when there is room for it, else in a legend +/// - color-by-task (bool): Give every task without a group its own palette colour. By default +/// such tasks share the first palette colour (grouped tasks keep one colour per group): a +/// colour per task would encode nothing +/// - today (none, int): Optional time index to draw a "today" marker line +/// - theme (none, dictionary): Theme overrides +/// - extra-legend-separation (length): Extra space between legend and chart +/// -> content +#let gantt-chart( + data, + width: auto, + height: auto, + title: none, + bar-height: auto, + gap: auto, + show-grid: true, + x-label: none, + show-legend: false, + color-by-task: false, + today: none, + theme: none, + extra-legend-separation: 0pt +) = context { + layout(size => { + validate-gantt-data(data, "gantt-chart") + let t = _resolve-ctx(theme) + let bar-height = if bar-height == auto { 1.75 * t.base-size } else { bar-height } + let gap = if gap == auto { 0.5 * t.base-size } else { gap } + let width = resolve-size(width, 0pt, size, n: 10, theme: t).width + let tasks = data.tasks + let n = tasks.len() + + // Columns are whole time units; round a fractional end up to include it + let time-count = int(calc.ceil(calc.max(..tasks.map(task => task.end)))) + let time-labels = if "time-labels" in data { data.time-labels } else { + array.range(time-count).map(i => str(i)) + } + let shown-labels = time-labels.slice(0, calc.min(time-count, time-labels.len())) + + // Group colours + let group-names = () + for task in tasks { + if "group" in task and task.group not in group-names { group-names.push(task.group) } + } + let has-groups = group-names.len() > 0 + + // The "Today" caption needs a band above the rows + let size-lbl = t.axis-label-size + let today-h = if today != none { text-height("Today", size-lbl) + t.axis-label-gap * 1.5 } else { 0pt } + let axis-h = x-axis-extent(t, title: x-label) + + // Rows: auto height keeps the old pitch (bar + gap); a given height shares it out + let auto-height = height == auto + let body-height = if auto-height { n * (bar-height + gap) + today-h + axis-h } else { height } + let pitch = if auto-height { bar-height + gap } else { (body-height - today-h - axis-h) / n } + let bar-h = if auto-height { bar-height } else { + calc.max(pitch * 0.4, calc.min(bar-height, pitch - gap)) + } + + // Overhang of the last, centred label past the plot + let probe = row-layout(width, body-height, tasks.map(task => task.name), t, label-frac: 0.3, pad-top: today-h, pad-bottom: axis-h, pitch: pitch) + let wmax = max-text-width(shown-labels, size-lbl) + let est-col = (width - probe.label-w - probe.gap) / time-count + let lay = row-layout(width, body-height, tasks.map(task => task.name), t, label-frac: 0.3, + pad-top: today-h, pad-bottom: axis-h, pitch: pitch, + inset-right: calc.max(0pt, wmax / 2 - est-col / 2)) + let (x0, x1) = (lay.x0, lay.x1) + let col-w = lay.plot-w / time-count + + // Group names go beside the first bar of each group when they fit there (in + // the room right of the bar, else left of it) and the rows are tall enough + // for the type; otherwise a legend carries them + let name-size = calc.min(size-lbl, lay.row-h) + let sw = name-size * 0.8 + let direct = if show-legend and has-groups and name-size >= 6pt { + let spots = group-names.enumerate().map(((gi, g)) => { + let fi = tasks.position(task => task.at("group", default: none) == g) + let w = sw + 3pt + text-width(g, name-size, weight: "bold") + let (xs, xe) = (x0 + tasks.at(fi).start * col-w, x0 + tasks.at(fi).end * col-w) + let x = if xe + 4pt + w <= width { xe + 4pt } else if xs - 4pt - w >= x0 { xs - 4pt - w } else { none } + (g: g, color: get-color(t, gi), y: row-y(lay, fi), x: x) + }) + if spots.all(sp => sp.x != none) { spots } else { none } + } else { none } + let legend = if show-legend and has-groups and direct == none { + draw-legend-auto(group-names.enumerate().map(((i, g)) => (name: g, color: get-color(t, i))), t, fit-width: true, max-width: legend-max-width(width, t)) + } else { none } + + chart-container(width, body-height, title, t, legend: legend, extra-legend-separation: extra-legend-separation)[ + #box(width: width, height: body-height)[ + // Column boundaries as light grid lines, thinned when columns are narrow + #if show-grid { + let g-stride = label-stride(1pt, col-w, gap: 5pt) + for i in array.range(time-count + 1) { + if calc.rem(i, g-stride) != 0 and i != time-count { continue } + place(left + top, line( + start: (x0 + i * col-w, lay.y0), + end: (x0 + i * col-w, lay.y1), + stroke: t.grid-stroke, + )) + } + } + + // Time axis: labels centred in their columns, thinned (never rotated) + #draw-x-axis( + shown-labels.enumerate().map(((i, l)) => (x: x0 + (i + 0.5) * col-w, label: l)), + lay.y1, t, x0: x0, x1: x1, title: x-label, + ) + #lay.labels + + #for (i, task) in tasks.enumerate() { + let color-idx = if has-groups { + let idx = group-names.position(g => g == task.at("group", default: "")) + if idx == none { i } else { idx } + } else if color-by-task { i } else { 0 } + place(left + top, dx: x0 + task.start * col-w, dy: row-y(lay, i) - bar-h / 2, + rect(width: (task.end - task.start) * col-w, height: bar-h, + fill: get-color(t, color-idx), stroke: none, radius: 2pt)) + } + + // Group names, set beside the first bar of their group + #if direct != none { + for sp in direct { + place-centered(sp.x, sp.y, grid(columns: (auto, auto), column-gutter: 3pt, align: horizon, + rect(width: sw, height: sw, fill: sp.color, radius: 1pt), + text(size: name-size, weight: "bold", fill: t.text-color)[#sp.g])) + } + } + + // "Today" marker, named in a caption above the rows + #if today != none { + let tx = x0 + today * col-w + place(left + top, line(start: (tx, lay.y0 - 3pt), end: (tx, lay.y1), + stroke: (paint: t.text-color, thickness: 1pt, dash: "dashed"))) + let w = text-width("Today", size-lbl) + place(left + top, dx: calc.max(0pt, calc.min(width - w, tx - w / 2)), dy: lay.y0 - 3pt - text-height("Today", size-lbl) - 2pt, + text(size: size-lbl, fill: t.text-color)[Today]) + } + ] + ] + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/gauge.typ b/packages/preview/primaviz/0.12.0/src/charts/gauge.typ new file mode 100644 index 0000000000..a9b9928c63 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/gauge.typ @@ -0,0 +1,320 @@ +// gauge.typ - Gauge/dial and progress indicators +#import "../theme.typ": _resolve-ctx, get-color, _shade +#import "../util.typ": nonzero, clamp, normalize-data +#import "../validate.typ": validate-number, validate-simple-data +#import "../primitives/container.typ": chart-container, container-inset +#import "../primitives/title.typ": draw-title +#import "../primitives/polar.typ": place-wedge, place-arc-band, separator-stroke +#import "../primitives/polar-labels.typ": label-ink, fit-text-size +#import "../primitives/radial.typ": title-for-size +#import "../primitives/layout.typ": resolve-size + +// A theme with a background (a dark one) brings it behind these standalone +// indicators, padded as a chart container pads a chart: their title and values +// are in the theme's light text, which a white page swallows. A theme without +// a background draws them bare, as they always were, to sit inline in a card. +#let _backed(t, body) = if t.background == none { body } else { + box(fill: t.background, stroke: t.border, inset: t.at("container-inset", default: container-inset), body) +} + +// The empty part of a progress indicator: a step off the background +#let _track(t, background) = { + if background != auto { background } + else if t.background != none { _shade(t, 20%) } + else { luma(230) } +} + +/// Renders a semicircular gauge/dial chart with a needle indicator. +/// +/// Without `segments` the dial is a quiet track filled up to the value; with +/// `segments` it is a set of flat coloured bands and the needle alone marks +/// the value. The title scales down on a small dial so it never outshouts +/// the heading of whatever the dial sits in. +/// +/// - value (int, float): Current value to display on the gauge +/// - min-val (int, float): Minimum scale value +/// - max-val (int, float): Maximum scale value +/// - size (length): Diameter of the gauge +/// - title (none, content): Optional chart title +/// - label (none, content): Descriptive label below the value +/// - show-value (bool): Display the numeric value in the center +/// - segments (none, array): Array of `(threshold, color)` pairs for colored arc segments +/// - needle-color (auto, color): Color of the needle and center cap +/// - theme (none, dictionary): Theme overrides +/// -> content +#let gauge-chart( + value, + min-val: 0, + max-val: 100, + size: 150pt, + title: none, + label: none, + show-value: true, + segments: none, + needle-color: auto, + theme: none, +) = context { + layout(avail => { + validate-number(value, "gauge-chart") + let t = _resolve-ctx(theme) + let size = resolve-size(size, size, avail).width + let needle-color = if needle-color == auto { t.text-color } else { needle-color } + let (r, hole) = (size / 2, size / 2 * 0.6) + let (cx, cy) = (size / 2, size / 2) + + let val-range = nonzero(max-val - min-val) + let normalized = clamp((value - min-val) / val-range, 0, 1) + // The dial runs from -180 (left) to 0 (right) + let at-deg(v) = -180 + clamp((v - min-val) / val-range, 0, 1) * 180 + + // Text under the hub: value, caption; scale ends sit under the band + let cap-r = calc.max(2pt, size * 0.04) + let value-text(sz) = text(size: sz, weight: "bold", fill: t.text-color, top-edge: "cap-height", bottom-edge: "baseline")[#calc.round(value, digits: 1)] + let scale-text(v) = text(size: t.axis-label-size, fill: t.text-color-light, top-edge: "cap-height", bottom-edge: "baseline")[#v] + let scale-w = calc.max(measure(scale-text(min-val)).width, measure(scale-text(max-val)).width) + // The value must stay clear of the scale ends either side of it + let value-size = fit-text-size(value-text, calc.max(r * 0.5, 2 * (r * 0.8 - scale-w / 2 - 3pt)), + calc.max(t.value-label-size, calc.min(size * 0.15, 14pt)), t.axis-label-size) + let value-h = if show-value { measure(value-text(value-size)).height } else { 0pt } + let caption = if label != none { + text(size: t.axis-label-size, fill: t.text-color-light, top-edge: "cap-height", bottom-edge: "descender")[#label] + } + let caption-h = if caption != none { measure(caption).height } else { 0pt } + let below = calc.max( + cap-r + 3pt + (if show-value { value-h + 3pt } else { 0pt }) + (if caption != none { caption-h + 1pt } else { 0pt }), + 2pt + measure(scale-text(max-val)).height) + let total-h = cy + below + + let ts = title-for-size(t, size) + chart-container(size, total-h, title, ts)[ + #box(width: size, height: total-h)[ + // The dial: a flat track, then either the bands or the filled value + #place-wedge(cx, cy, hole, r, -180, 0, _track(t, auto)) + #if segments != none { + let prev = min-val + for seg in segments { + place-wedge(cx, cy, hole, r, at-deg(prev), at-deg(seg.at(0)), seg.at(1), + stroke: separator-stroke(t, thickness: 0.75pt)) + prev = seg.at(0) + } + } else if "gauge-low-color" in t or "gauge-mid-color" in t or "gauge-high-color" in t { + // Opt-in zones through the theme: thirds of the dial + let zones = ( + t.at("gauge-low-color", default: get-color(t, 4)), + t.at("gauge-mid-color", default: get-color(t, 5)), + t.at("gauge-high-color", default: get-color(t, 2)), + ) + for (i, zc) in zones.enumerate() { + place-wedge(cx, cy, hole, r, -180 + i * 60, -180 + (i + 1) * 60, zc, + stroke: separator-stroke(t, thickness: 0.75pt)) + } + } else { + place-wedge(cx, cy, hole, r, -180, -180 + normalized * 180, get-color(t, 0)) + } + + // Needle and hub + #let needle-angle = (-180 + normalized * 180) * 1deg + #place(left + top, line(start: (cx, cy), + end: (cx + r * 0.85 * calc.cos(needle-angle), cy + r * 0.85 * calc.sin(needle-angle)), + stroke: needle-color + calc.max(1pt, size * 0.016))) + #place(left + top, dx: cx - cap-r, dy: cy - cap-r, + circle(radius: cap-r, fill: needle-color, stroke: none)) + + // Scale ends, centred under the band + #for (v, sign) in ((min-val, -1), (max-val, 1)) { + place(left + top, dx: cx + sign * r * 0.8 - 20pt, dy: cy + 2pt, + box(width: 40pt, align(center, scale-text(v)))) + } + + // Value and caption, centred under the hub + #if show-value { + place(left + top, dx: cx - r, dy: cy + cap-r + 3pt, box(width: 2 * r, align(center, value-text(value-size)))) + } + #if caption != none { + place(left + top, dx: cx - r, dy: cy + cap-r + 3pt + (if show-value { value-h + 3pt } else { 0pt }), + box(width: 2 * r, align(center, caption))) + } + ] + ] + }) +} + +/// Renders a horizontal progress bar. +/// +/// The percentage sits inside the fill when it fits, and just past the end +/// of the fill, on the track, when it does not. +/// +/// - value (int, float): Current progress value +/// - max-val (int, float): Maximum value (100% fill) +/// - width (length): Bar width +/// - height (length): Bar height +/// - title (none, content): Optional label above the bar +/// - show-value (bool): Display percentage text on the bar +/// - color (none, color): Override fill color +/// - background (color): Track background color +/// - rounded (bool): Use rounded ends +/// - theme (none, dictionary): Theme overrides +/// -> content +#let progress-bar( + value, + max-val: 100, + width: 200pt, + height: 20pt, + title: none, + show-value: true, + color: none, + background: auto, + rounded: true, + theme: none, +) = context { + layout(size => { + validate-number(value, "progress-bar") + let t = _resolve-ctx(theme) + set text(size: 11pt) // sized by the theme, not the document font + let (width, height) = resolve-size(width, height, size, theme: t, container: t.background != none) + let progress = clamp(value / nonzero(max-val), 0, 1) + let bar-color = if color != none { color } else { get-color(t, 0) } + let corner = if rounded { height / 2 } else { 0pt } + + // Digits are about 0.7 em tall: as big as the bar allows, never below the + // theme's smallest type, and no label at all in a bar too thin to hold one + let pct-size = calc.max(t.axis-label-size, calc.min(t.value-label-size, (height - 3pt) / 0.7)) + let pct-text(ink) = text(size: pct-size, weight: "bold", fill: ink, + top-edge: "cap-height", bottom-edge: "baseline")[#calc.round(progress * 100, digits: 0)%] + let pct-w = measure(pct-text(black)).width + let pad = calc.max(4pt, corner * 0.8) + let fill-w = width * progress + let inside = progress > 0 and fill-w >= pct-w + 2 * pad + let pct-x = if inside { fill-w - pad - pct-w } else { calc.min(fill-w + pad, width - pad - pct-w) } + let pct-ink = if inside { label-ink(bar-color, t) } else { t.text-color } + + _backed(t, box(width: width)[ + #stack(dir: ttb, spacing: 3pt, + if title != none { text(size: t.value-label-size, fill: t.text-color, bottom-edge: "descender")[#title] }, + box(width: width, height: height)[ + #place(left + top, rect(width: width, height: height, fill: _track(t, background), radius: corner)) + #if progress > 0 { + place(left + top, rect(width: fill-w, height: height, fill: bar-color, radius: corner)) + } + #if show-value and height >= 6pt { + place(left + horizon, dx: pct-x, pct-text(pct-ink)) + } + ]) + ]) + }) +} + +/// Renders a circular progress ring indicator. +/// +/// - value (int, float): Current progress value +/// - max-val (int, float): Maximum value (100% fill) +/// - size (length): Outer diameter of the ring +/// - title (none, content): Optional label above the ring +/// - show-value (bool): Display percentage text in the center +/// - stroke-width (length): Thickness of the ring stroke +/// - color (none, color): Override ring color +/// - background (color): Track background color +/// - theme (none, dictionary): Theme overrides +/// -> content +#let circular-progress( + value, + max-val: 100, + size: 80pt, + title: none, + show-value: true, + stroke-width: 8pt, + color: none, + background: auto, + theme: none, +) = context { + layout(avail => { + validate-number(value, "circular-progress") + let t = _resolve-ctx(theme) + set text(size: 11pt) // sized by the theme, not the document font + let size = resolve-size(size, size, avail, theme: t, container: t.background != none).width + let progress = clamp(value / nonzero(max-val), 0, 1) + let bar-color = if color != none { color } else { get-color(t, 0) } + let sw = calc.min(stroke-width, size * 0.3) + let (cx, cy) = (size / 2, size / 2) + let ring-r = size / 2 - sw / 2 + + // The percentage, as big as the hole allows + let pct-text(sz) = text(size: sz, weight: "bold", fill: t.text-color, top-edge: "cap-height", bottom-edge: "baseline")[#calc.round(progress * 100, digits: 0)%] + let pct-size = fit-text-size(pct-text, (ring-r - sw / 2) * 1.5, t.value-label-size, t.axis-label-size) + + _backed(t, box(width: size)[ + #stack(dir: ttb, spacing: 3pt, + if title != none { align(center, text(size: t.value-label-size, fill: t.text-color)[#title]) }, + box(width: size, height: size)[ + #place-arc-band(cx, cy, ring-r, sw, -90, 270, _track(t, background)) + #place-arc-band(cx, cy, ring-r, sw, -90, -90 + progress * 360, bar-color, round: true) + #if show-value { + place(center + horizon, pct-text(pct-size)) + } + ]) + ]) + }) +} + +/// Renders multiple labeled progress bars for comparing values. +/// +/// - data (dictionary, array): Dict with `labels` and `values` arrays, or an array of label-value pairs +/// - width (length): Overall width +/// - bar-height (length): Height of each bar +/// - title (none, content): Optional title above the bars +/// - show-values (bool): Display numeric values beside bars +/// - max-val (auto, int, float): Maximum scale value; `auto` uses the data maximum +/// - color (none, auto, color): Fill of the bars. `none` (default): the theme's first colour for every bar, the labels say which is which. A colour: that colour. `auto`: the next palette colour for each bar +/// - background (color): Track background color +/// - theme (none, dictionary): Theme overrides +/// -> content +#let progress-bars( + data, + width: 250pt, + bar-height: 16pt, + title: none, + show-values: true, + max-val: auto, + color: none, + background: auto, + theme: none, +) = context { + layout(size => { + validate-simple-data(data, "progress-bars") + let t = _resolve-ctx(theme) + set text(size: 11pt) // sized by the theme, not the document font + let width = resolve-size(width, 0pt, size, theme: t, container: t.background != none).width + let norm = normalize-data(data) + let (labels, values) = (norm.labels, norm.values) + let actual-max = nonzero(if max-val == auto { calc.max(..values) } else { max-val }) + // (a bar's colour does not say which bar it is: the label beside it does) + let fill-of(i) = if color == auto { get-color(t, i) } else if color != none { color } else { get-color(t, 0) } + + // Names are as wide as they are, up to 40% of the whole: past that they wrap + // rather than squeeze the bars to stubs + let name-w = calc.min(width * 0.4, + calc.max(..labels.map(l => measure(text(size: t.value-label-size)[#l]).width))) + + _backed(t, box(width: width)[ + #draw-title(title, t) + #grid( + columns: if show-values { (name-w, 1fr, auto) } else { (name-w, 1fr) }, + column-gutter: 6pt, + row-gutter: 6pt, + align: (right + horizon, left + horizon, left + horizon), + ..labels.enumerate().map(((i, lbl)) => { + let progress = clamp(values.at(i) / actual-max, 0, 1) + let row = ( + text(size: t.value-label-size, fill: t.text-color)[#lbl], + box(width: 100%, height: bar-height)[ + #rect(width: 100%, height: 100%, fill: _track(t, background), radius: bar-height / 2) + #place(left + top, rect(width: 100% * progress, height: 100%, fill: fill-of(i), radius: bar-height / 2)) + ], + ) + if show-values { row.push(text(size: t.value-label-size, fill: t.text-color, weight: "bold")[#values.at(i)]) } + row + }).flatten() + ) + ]) + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/heatmap.typ b/packages/preview/primaviz/0.12.0/src/charts/heatmap.typ new file mode 100644 index 0000000000..34696c9947 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/heatmap.typ @@ -0,0 +1,300 @@ +// heatmap.typ - Heatmap/matrix charts +#import "../theme.typ": _resolve-ctx, get-color, _phi, _shade +#import "../util.typ": heat-color, nonzero, contrast-text +#import "../validate.typ": validate-heatmap-data, validate-calendar-data, validate-correlation-data +#import "../primitives/container.typ": chart-container, container-inset +#import "../primitives/layout.typ": text-width, text-height, max-text-width, place-centered +#import "../primitives/matrix.typ": matrix-layout, cell-label-size +#import "../primitives/legend.typ": draw-gradient-legend, gradient-legend-width + +// A labelled grid of colour-coded cells with a gradient key: the body shared +// by `heatmap` and `correlation-matrix`. Label gutters, cell size and the +// container's height all come from measurement, so the chart is exactly as big +// as its content: no dead bands, no label wider than its cell. +// +// - norm: function mapping a cell value to 0..1 for the palette +// - lo, hi: the values the key's ends stand for +// - digits: decimals printed inside the cells +#let _matrix-chart( + rows, cols, values, avail, t, + cell-size, palette, reverse, show-values, show-legend, + norm, lo, hi, digits, + title, subtitle, radius, +) = { + let n-rows = rows.len() + let n-cols = cols.len() + let ci = t.at("container-inset", default: container-inset) + + // The key: a gradient bar with its end labels, as wide as those labels need + let key-bar-w = 15pt + let key-w = if show-legend { gradient-legend-width(lo, hi, t, bar-width: key-bar-w) + t.legend-gap } else { 0pt } + + // Cells shrink to the room there is: row gutter (measured) + key + insets + let avail-w = if type(avail.width) == length and avail.width > 0pt { avail.width } else { none } + let probe = matrix-layout(rows, cols, cell-size, cell-size, t) + let cell = if avail-w != none and probe.row-w + n-cols * cell-size + key-w + 2 * ci > avail-w { + calc.max(2pt, (avail-w - probe.row-w - key-w - 2 * ci) / n-cols) + } else { cell-size } + let lay = matrix-layout(rows, cols, cell, cell, t) + + let grid-w = n-cols * cell + let grid-h = n-rows * cell + let body-w = lay.gx + calc.max(grid-w + key-w, grid-w + lay.overhang) + let body-h = lay.gy + grid-h + + // The key sits against the grid's vertical middle; its end labels hang + // outside the bar, so keep the bar clear of the grid's top and bottom + let key-h = calc.max(20pt, calc.min(grid-h * 0.8, grid-h - 2 * (t.axis-label-size + 4pt))) + let key-y = lay.gy + (grid-h - key-h) / 2 + + let value-size = if show-values { + cell-label-size(values.flatten().map(v => str(calc.round(v, digits: digits))), cell, cell, t) + } else { none } + + chart-container(body-w, body-h, title, t, subtitle: subtitle, radius: radius)[ + #box(width: body-w, height: body-h)[ + #lay.labels + + // Fills first, then the values, so a value never loses a tail to the + // next cell's fill + #let fills = values.map(row => row.map(v => heat-color(norm(v), palette: palette, reverse: reverse))) + #for (i, row-fills) in fills.enumerate() { + for (j, fill) in row-fills.enumerate() { + place(left + top, dx: lay.gx + j * cell, dy: lay.gy + i * cell, + rect(width: cell, height: cell, fill: fill, stroke: t.marker-stroke)) + } + } + #if value-size != none { + for (i, row) in values.enumerate() { + for (j, v) in row.enumerate() { + place(left + top, dx: lay.gx + j * cell, dy: lay.gy + i * cell, + box(width: cell, height: cell, align(center + horizon, + text(size: value-size, fill: contrast-text(fills.at(i).at(j), theme: t))[#calc.round(v, digits: digits)]))) + } + } + } + + #if show-legend { + place(left + top, dx: lay.gx + grid-w + t.legend-gap, dy: key-y, + draw-gradient-legend(lo, hi, palette, t, bar-width: key-bar-w, bar-height: key-h, reverse: reverse)) + } + ] + ] +} + +/// Renders a heatmap grid with color-coded cells. +/// +/// - data (dictionary): Dict with `rows` (row labels), `cols` (column labels), and `values` (2D array) +/// - cell-size (length): Width and height of each cell +/// - title (none, content): Optional chart title +/// - show-values (bool): Display numeric values inside cells +/// - palette (str, array): Color palette name or array of color stops. Append `"-r"` to reverse named palettes. +/// - show-legend (bool): Show color scale legend +/// - reverse (bool): Reverse palette direction +/// - subtitle (none, content): Optional subtitle below the title +/// - radius (length): Corner radius of the chart container +/// - theme (none, dictionary): Theme overrides +/// -> content +#let heatmap( + data, + cell-size: auto, + title: none, + show-values: true, + palette: "viridis", + show-legend: true, + reverse: false, + subtitle: none, + radius: 0pt, + theme: none, +) = context { + layout(avail => { + validate-heatmap-data(data, "heatmap") + let t = _resolve-ctx(theme) + // Default cell-size scales from seeds: base-gap × φ³ ≈ 25pt at default + let cell-size = if cell-size == auto { t.cell-size } else { cell-size } + + let all-vals = data.values.flatten() + let min-val = calc.min(..all-vals) + let max-val = calc.max(..all-vals) + let val-range = nonzero(max-val - min-val) + + _matrix-chart( + data.rows, data.cols, data.values, avail, t, + cell-size, palette, reverse, show-values, show-legend, + v => (v - min-val) / val-range, min-val, max-val, 1, + title, subtitle, radius, + ) + }) +} + +/// Renders a calendar-style heatmap grid (similar to a GitHub contribution graph). +/// +/// Values run from a pale tint to a dark one along an ordered ramp, so more is +/// always darker; days without data stay a flat pale grey. The key at the +/// bottom uses cells of the same size as the data cells. +/// +/// - data (dictionary): Dict with `dates` (array of `"YYYY-MM-DD"` strings) and `values` (array of numbers) +/// - cell-size (length): Size of each day cell +/// - title (none, content): Optional chart title +/// - palette (str, array): Color palette name or array of color stops +/// - show-month-labels (bool): Display month labels above the grid +/// - show-day-labels (bool): Display day-of-week labels on the left +/// - reverse (bool): Reverse palette direction +/// - theme (none, dictionary): Theme overrides +/// -> content +#let calendar-heatmap( + data, + cell-size: 12pt, + title: none, + palette: "heat", + show-month-labels: true, + show-day-labels: true, + reverse: false, + theme: none, +) = context { + validate-calendar-data(data, "calendar-heatmap") + let t = _resolve-ctx(theme) + // Place each value by its actual date: gaps between dates stay empty and + // repeated dates add up, so sparse data (e.g. weekly) lands on the right day + let parse(dt) = { + let p = dt.split("-") + assert(p.len() == 3, message: "calendar-heatmap: dates must be YYYY-MM-DD, got " + repr(dt)) + datetime(year: int(p.at(0)), month: int(p.at(1)), day: int(p.at(2))) + } + let days = data.dates.map(parse) + let first = days.fold(days.at(0), (a, d) => if d < a { d } else { a }) + let last = days.fold(days.at(0), (a, d) => if d > a { d } else { a }) + let span = int((last - first).days()) + 1 + let by-offset = (:) + for (d, v) in days.zip(data.values) { + let k = str(int((d - first).days())) + by-offset.insert(k, by-offset.at(k, default: 0) + v) + } + let values = by-offset.values() + + // Colour scale over the non-zero values; the ramp starts a little above its + // palest end so the lowest day is still distinct from a day with no data + let non-zero-vals = values.filter(v => v > 0) + let min-val = if non-zero-vals.len() > 0 { calc.min(..non-zero-vals) } else { 0 } + let max-val = calc.max(..values) + let val-range = nonzero(max-val - min-val) + let ramp(x) = heat-color(0.18 + 0.82 * x, palette: palette, reverse: reverse) + + // Starting day-of-week offset (0=Mon..6=Sun); grid slots rounded up to full weeks + let start-dow = first.weekday() - 1 + let n-weeks = calc.ceil((start-dow + span) / 7) + + let cell = cell-size + let gap = calc.min(t.cell-gap, cell * 0.15) + let inner = cell - gap + let radius = calc.min(2pt, inner * 0.25) + let empty-fill = if t.background != none { _shade(t, 15%) } else { t.text-color-light.transparentize(80%) } + + // Day gutter measured from the day names; month band from the label height + let day-names = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") + let month-names = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") + let dl = matrix-layout(day-names, (), cell, cell, t) + let lbl-size = dl.size + let day-w = if show-day-labels { dl.row-w } else { 0pt } + let month-h = if show-month-labels { text-height("Mg", lbl-size) + t.label-offset + 1pt } else { 0pt } + + let grid-w = n-weeks * cell + let grid-h = 7 * cell + let key-gap = t.legend-gap + let key-labels = (text-width("Less", lbl-size), text-width("More", lbl-size)) + let key-w = key-labels.at(0) + key-labels.at(1) + 5 * cell + 2 * t.label-offset + let title-w = if title != none { text-width(title, t.title-size, weight: t.title-weight) } else { 0pt } + // Wide enough for the key and title too, so short ranges don't wrap them + let body-w = calc.max(day-w + grid-w, key-w, title-w) + let x0 = (body-w - day-w - grid-w) / 2 // centres the grid + let body-h = month-h + grid-h + key-gap + inner + + align(center, chart-container(body-w, body-h, title, t)[ + #box(width: body-w, height: body-h)[ + // Month names above the week that starts them, skipping any that would + // touch the previous one + #if show-month-labels { + let prev-end = -100pt + let prev-month = 0 + for k in range(span) { + let d = first + duration(days: k) + if d.month() == prev-month { continue } + prev-month = d.month() + let label-x = x0 + day-w + calc.floor((start-dow + k) / 7) * cell + let name = month-names.at(d.month() - 1) + let w = text-width(name, lbl-size) + if label-x >= prev-end + 4pt { + place(left + top, dx: label-x, dy: 0pt, text(size: lbl-size, fill: t.text-color)[#name]) + prev-end = label-x + w + } + } + } + + #if show-day-labels { place(left + top, dx: x0, dy: month-h, dl.labels) } + + // Every slot starts as an empty cell (padding and dates without data) + #for slot in range(n-weeks * 7) { + place(left + top, + dx: x0 + day-w + calc.floor(slot / 7) * cell, dy: month-h + calc.rem(slot, 7) * cell, + rect(width: inner, height: inner, fill: empty-fill, stroke: none, radius: radius)) + } + + // Data cells, positioned by date + #for (k, val) in by-offset { + if val == 0 { continue } + let slot = start-dow + int(k) + place(left + top, + dx: x0 + day-w + calc.floor(slot / 7) * cell, dy: month-h + calc.rem(slot, 7) * cell, + rect(width: inner, height: inner, fill: ramp((val - min-val) / val-range), stroke: none, radius: radius)) + } + + // Key: Less, five cells of the data cells' size, More; centred under the grid + #let key-y = month-h + grid-h + key-gap + #let key-x = (body-w - key-w) / 2 + #place(left + top, dx: key-x, dy: key-y + inner / 2, + box(height: 0pt, align(horizon, text(size: lbl-size, fill: t.text-color)[Less]))) + #for i in range(5) { + place(left + top, dx: key-x + key-labels.at(0) + t.label-offset + i * cell, dy: key-y, + rect(width: inner, height: inner, fill: ramp(i / 4), stroke: none, radius: radius)) + } + #place(left + top, dx: key-x + key-labels.at(0) + t.label-offset + 5 * cell + t.label-offset - gap, dy: key-y + inner / 2, + box(height: 0pt, align(horizon, text(size: lbl-size, fill: t.text-color)[More]))) + ] + ]) +} + +/// Renders a correlation matrix as a symmetric heatmap with configurable palette. +/// +/// - data (dictionary): Dict with `labels` and `values` (2D symmetric array, -1 to 1 range) +/// - cell-size (length): Width and height of each cell +/// - title (none, content): Optional chart title +/// - show-values (bool): Display correlation values inside cells +/// - palette (str, array): Color palette name or array of color stops. Append `"-r"` to reverse named palettes. +/// - show-legend (bool): Show color scale legend +/// - reverse (bool): Reverse palette direction +/// - theme (none, dictionary): Theme overrides +/// -> content +#let correlation-matrix( + data, + cell-size: auto, + title: none, + show-values: true, + palette: "coolwarm", + show-legend: true, + reverse: false, + theme: none, +) = context { + layout(avail => { + validate-correlation-data(data, "correlation-matrix") + let t = _resolve-ctx(theme) + let cell-size = if cell-size == auto { t.cell-size * _phi } else { cell-size } + + _matrix-chart( + data.labels, data.labels, data.values, avail, t, + cell-size, palette, reverse, show-values, show-legend, + // Correlation range [-1, +1] maps onto the palette's 0..1 + v => (calc.max(-1, calc.min(1, v)) + 1) / 2, -1, 1, 2, + title, none, 0pt, + ) + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/histogram.typ b/packages/preview/primaviz/0.12.0/src/charts/histogram.typ new file mode 100644 index 0000000000..5e63676100 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/histogram.typ @@ -0,0 +1,107 @@ +// histogram.typ - Histogram chart (frequency distribution of numeric data) +#import "../theme.typ": _resolve-ctx, get-color +#import "../util.typ": nice-ticks +#import "../validate.typ": validate-histogram-data +#import "../primitives/frame.typ": cartesian-frame +#import "../primitives/axes.typ": value-axis, fit-label-size +#import "../primitives/layout.typ": resolve-size, pin + +/// Renders a histogram showing the frequency distribution of numeric data. +/// +/// With everything automatic (`bins`, `min-val`, `max-val`) the bin edges are +/// round numbers; otherwise the bins are evenly spaced from `min-val` to +/// `max-val`. Either way the x ticks sit on bin edges. +/// +/// - values (array): Array of numeric data values to bin +/// - width (length): Chart width +/// - height (length): Chart height +/// - title (none, content): Optional chart title +/// - bins (auto, int): Number of bins; `auto` uses Sturges' rule +/// - min-val (auto, int, float): Minimum bin edge; `auto` uses data minimum +/// - max-val (auto, int, float): Maximum bin edge; `auto` uses data maximum +/// - show-values (bool): Display count labels above bars +/// - color (none, color): Override bar color +/// - density (bool): Normalize to probability density instead of counts +/// - x-label (none, content): X-axis title +/// - y-label (none, content): Y-axis title +/// - annotations (none, array): Optional annotation descriptors +/// - show-ticks (bool): Draw tick marks on the axis lines +/// - show-minor-grid (bool): Draw lighter minor grid lines between the major ones +/// - theme (none, dictionary): Theme overrides +/// -> content +#let histogram( + values, + width: auto, + height: auto, + title: none, + bins: auto, + min-val: auto, + max-val: auto, + show-values: false, + color: none, + density: false, + x-label: none, + y-label: none, + annotations: none, + show-ticks: false, + show-minor-grid: false, + theme: none, +) = context { + layout(size => { + validate-histogram-data(values, "histogram") + let t = _resolve-ctx(theme) + let (width, height) = resolve-size(width, height, size, n: 10, theme: t) + let n = values.len() + + let data-min = if min-val == auto { calc.min(..values) } else { min-val } + let data-max = if max-val == auto { calc.max(..values) } else { max-val } + // All values identical: give the single bin some width + if data-min == data-max { data-max = data-min + 1 } + + // Bin edges: round numbers when everything is automatic, otherwise even steps + let sturges = calc.ceil(calc.log(n, base: 2) + 1) + let edges = if bins == auto and min-val == auto and max-val == auto { + nice-ticks(data-min, data-max, count: sturges + 1).ticks + } else { + let nb = if bins == auto { sturges } else { bins } + let step = (data-max - data-min) / nb + range(nb + 1).map(i => data-min + i * step) + } + let num-bins = edges.len() - 1 + let bin-width = edges.at(1) - edges.at(0) + + let counts = range(num-bins).map(bi => values.filter(v => { + let last = bi == num-bins - 1 + v >= edges.at(bi) and (if last { v <= edges.at(bi + 1) } else { v < edges.at(bi + 1) }) + }).len()) + let heights = if density { counts.map(c => c / (n * bin-width)) } else { counts.map(c => float(c)) } + + // Ticks sit on bin edges (every k-th when they are too many), with just + // enough decimals to tell neighbouring edges apart + let edge-digits = calc.max(0, 1 - calc.floor(calc.log(bin-width, base: 10))) + let edge-ticks = (min: edges.first(), max: edges.last(), ticks: edges, step: bin-width, digits: edge-digits, thin: true) + let fill-color = if color != none { color } else { get-color(t, 0) } + + cartesian-frame(width, height, t, + x: value-axis(edges.first(), edges.last(), title: x-label, ticks: edge-ticks), + y: value-axis(0, calc.max(..heights), title: y-label), + title: title, show-ticks: show-ticks, show-minor-grid: show-minor-grid, + baseline: (y: 0), annotations: annotations, + reserve: (top: if show-values { t.value-label-size + t.label-offset } else { 0pt }), + p => { + let (px, py) = (p.px, p.py) + let vsize = fit-label-size(counts.map(str), p.w / num-bins, t.value-label-size) + for bi in range(num-bins) { + let (x-left, x-right, y-top) = (px(edges.at(bi)), px(edges.at(bi + 1)), py(heights.at(bi))) + place(left + top, dx: x-left, dy: y-top, + rect(width: x-right - x-left, height: py(0) - y-top, fill: fill-color, stroke: t.marker-stroke)) + if show-values and counts.at(bi) > 0 and vsize != none { + place(left + top, dx: x-left, dy: y-top - t.label-offset, + pin(width: x-right - x-left, align: center + bottom, + text(size: vsize, fill: t.text-color)[#counts.at(bi)])) + } + } + }, + ) + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/line.typ b/packages/preview/primaviz/0.12.0/src/charts/line.typ new file mode 100644 index 0000000000..4ed18f23ad --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/line.typ @@ -0,0 +1,286 @@ +// line.typ - Line charts (single and multi-series) +#import "../theme.typ": _resolve-ctx, get-color +#import "../util.typ": normalize-data, normalize-errors +#import "../validate.typ": validate-simple-data, validate-series-data, validate-line-style +#import "../primitives/frame.typ": cartesian-frame +#import "../primitives/axes.typ": value-axis, category-axis, fit-label-size, value-text +#import "../primitives/legend.typ": draw-legend-auto, legend-max-width +#import "../primitives/layout.typ": resolve-size, pin +#import "../primitives/paths.typ": round-stroke + +// Moving average of the y values over `smooth-radius` neighbours on each side. A radius +// of 0 leaves every point where it is, so the curve through them interpolates the data. +#let _smooth-points(points, smooth-radius) = { + let smoothed = () + for i in array.range(points.len()) { + let pt = points.at(i) + let lo = calc.max(i - smooth-radius, 0) + let hi = calc.min(i + smooth-radius, points.len() - 1) + let total-y = 0pt + let count = 0 + for j in array.range(lo, hi + 1) { + total-y += points.at(j).at(1) + count += 1 + } + smoothed.push((pt.at(0), total-y / count)) + } + smoothed +} + +#let _line-curve-components(points, line-interpolation) = { + let comps = (curve.move(points.first()),) + for i in array.range(points.len() - 1) { + let p1 = points.at(i) + let p2 = points.at(i + 1) + if line-interpolation == "linear" { + comps.push(curve.line(p2)) + } else if line-interpolation == "smooth" { + let mid-x = (p1.at(0) + p2.at(0)) / 2 + comps.push(curve.cubic((mid-x, p1.at(1)), (mid-x, p2.at(1)), p2)) + } else if line-interpolation == "catmull-rom" { + let p0 = points.at(calc.max(i - 1, 0)) + let p3 = points.at(calc.min(i + 2, points.len() - 1)) + let c1 = (p1.at(0) + (p2.at(0) - p0.at(0)) / 6, p1.at(1) + (p2.at(1) - p0.at(1)) / 6) + let c2 = (p2.at(0) - (p3.at(0) - p1.at(0)) / 6, p2.at(1) - (p3.at(1) - p1.at(1)) / 6) + comps.push(curve.cubic(c1, c2, p2)) + } + } + comps +} + +#let draw-line-series(points, stroke, line-interpolation, smooth-radius) = { + if points.len() <= 1 { + return + } + + let curve-points = if line-interpolation == "smooth" { + _smooth-points(points, smooth-radius) + } else { + points + } + place( + left + top, + curve(stroke: round-stroke(stroke), fill: none, .._line-curve-components(curve-points, line-interpolation)) + ) +} + +// Fills the region between the line and `baseline-y`, following the same +// interpolation as the drawn line so fill and stroke share an edge. +#let draw-line-fill(points, fill, baseline-y, line-interpolation, smooth-radius) = { + if points.len() <= 1 { + return + } + + let curve-points = if line-interpolation == "smooth" { + _smooth-points(points, smooth-radius) + } else { + points + } + place( + left + top, + curve( + stroke: none, + fill: fill, + .._line-curve-components(curve-points, line-interpolation), + curve.line((curve-points.last().at(0), baseline-y)), + curve.line((curve-points.first().at(0), baseline-y)), + curve.close(mode: "straight"), + ) + ) +} + +// A marker on a series point. +#let _dot(pt, size, fill, t) = place(left + top, dx: pt.at(0) - size / 2, dy: pt.at(1) - size / 2, + circle(radius: size / 2, fill: fill, stroke: t.marker-stroke)) + +// Room a marker of `size` needs at the ends of an axis. +#let _marker-inset(show-points, size, line-width) = (if show-points { size } else { line-width }) / 2 + 1pt + +/// Renders a single-series line chart. +/// +/// - data (dictionary, array): Label-value pairs as dict or array of tuples +/// - width (length): Chart width +/// - height (length): Chart height +/// - title (none, content): Optional chart title +/// - show-points (bool): Draw data point markers +/// - show-values (bool): Display value labels at data points +/// - line-width (auto, length): Stroke width of the line; `auto` uses the theme's `stroke-thick` +/// - line-interpolation (str): "linear", "smooth", or "catmull-rom". Smooth and catmull-rom +/// curves pass through every data point (smooth with level tangents at each point, so +/// it never overshoots); markers, value labels and error bars always sit on the data +/// - smooth-radius (int): For `"smooth"`: 0 draws the curve through the data points; +/// 1 to 5 draws a trend, the curve through a moving average of that radius, which +/// no longer meets the points (they stay at their true values) +/// - point-size (length): Diameter of point markers +/// - fill-area (bool): Fill the area under the line +/// - fill-opacity (ratio): Opacity of the filled area +/// - x-label (none, content): X-axis title +/// - y-label (none, content): Y-axis title +/// - errors (none, array): Symmetric `(e1, e2, ...)` or asymmetric `((lo1, hi1), ...)` +/// - error-color (auto, color): Error bar color; `auto` uses the series color +/// - error-cap-width (length): Width of the error bar caps +/// - annotations (none, array): Optional annotation descriptors +/// - show-ticks (bool): Draw tick marks on the axis lines +/// - show-minor-grid (bool): Draw lighter minor grid lines between the major ones +/// - subtitle (none, content): Optional subtitle below the title +/// - radius (length): Corner radius of the chart container +/// - theme (none, dictionary): Theme overrides +/// - extra-legend-separation (length): Extra space between legend and chart +/// -> content +#let line-chart( + data, + width: auto, + height: auto, + title: none, + show-points: true, + show-values: false, + line-width: auto, + line-interpolation: "linear", + smooth-radius: 0, + point-size: 4pt, + fill-area: false, + fill-opacity: 40%, + x-label: none, + y-label: none, + errors: none, + error-color: auto, + error-cap-width: 6pt, + annotations: none, + show-ticks: false, + show-minor-grid: false, + subtitle: none, + radius: 0pt, + theme: none, + extra-legend-separation: 0pt +) = context { + layout(size => { + validate-simple-data(data, "line-chart") + validate-line-style(line-interpolation, smooth-radius, "line-chart") + let t = _resolve-ctx(theme) + let line-width = if line-width == auto { t.stroke-thick } else { line-width } + let norm = normalize-data(data) + let (labels, values) = (norm.labels, norm.values) + let n = values.len() + let (width, height) = resolve-size(width, height, size, n: n, theme: t) + let errs = normalize-errors(errors, n) + let strs = values.map(v => value-text(v, t)) + let color = get-color(t, 0) + + // The axis covers the error bars too + let lo = calc.min(..values.enumerate().map(((i, v)) => v - if errs != none { errs.at(i).low } else { 0 })) + let hi = calc.max(..values.enumerate().map(((i, v)) => v + if errs != none { errs.at(i).high } else { 0 })) + let inset = _marker-inset(show-points, point-size, line-width) + + cartesian-frame(width, height, t, + x: category-axis(labels, title: x-label, mode: "points", inset: inset), + y: value-axis(lo, hi, title: y-label, inset: inset), + title: title, subtitle: subtitle, radius: radius, + show-ticks: show-ticks, show-minor-grid: show-minor-grid, + extra-legend-separation: extra-legend-separation, annotations: annotations, + p => { + let (px, py) = (p.px, p.py) + let points = values.enumerate().map(((i, v)) => (px(i), py(v))) + + // Fill baseline: zero, or the axis floor when the visible range excludes it + if fill-area { + let baseline-y = calc.max(p.y0, calc.min(p.y1, py(0))) + draw-line-fill(points, color.transparentize(100% - fill-opacity), baseline-y, line-interpolation, smooth-radius) + } + + draw-line-series(points, line-width + color, line-interpolation, smooth-radius) + + // Error bars (under the points, so the marker sits on top) + if errs != none { + let err-stroke = t.stroke-thin + if error-color != auto { error-color } else { color } + for (i, pt) in points.enumerate() { + let (y-low, y-high) = (py(values.at(i) - errs.at(i).low), py(values.at(i) + errs.at(i).high)) + let cx = pt.at(0) + place(left + top, line(start: (cx, y-low), end: (cx, y-high), stroke: err-stroke)) + for y in (y-low, y-high) { + place(left + top, line(start: (cx - error-cap-width / 2, y), end: (cx + error-cap-width / 2, y), stroke: err-stroke)) + } + } + } + + let vsize = fit-label-size(strs, p.X.pitch, t.value-label-size) + for (i, pt) in points.enumerate() { + if show-points { _dot(pt, point-size, color, t) } + if show-values and vsize != none { + let lift = if show-points { point-size / 2 } else { 0pt } + place(left + top, dx: pt.at(0) - p.X.pitch / 2, dy: pt.at(1) - lift - t.label-offset, + pin(width: p.X.pitch, align: center + bottom, text(size: vsize, fill: t.text-color)[#strs.at(i)])) + } + } + }, + ) + }) +} + +/// Renders a multi-series line chart with a shared axis. +/// +/// - data (dictionary): Dict with `labels` and `series` (each with `name` and `values`) +/// - width (length): Chart width +/// - height (length): Chart height +/// - title (none, content): Optional chart title +/// - show-points (bool): Draw data point markers +/// - show-legend (bool): Show series legend +/// - line-interpolation (str): "linear", "smooth", or "catmull-rom". Smooth and catmull-rom +/// curves pass through every data point (smooth with level tangents at each point, so +/// it never overshoots); markers, value labels and error bars always sit on the data +/// - smooth-radius (int): For `"smooth"`: 0 draws the curve through the data points; +/// 1 to 5 draws a trend, the curve through a moving average of that radius, which +/// no longer meets the points (they stay at their true values) +/// - line-width (auto, length): Stroke width of each series line; `auto` uses the theme's `stroke-thick` +/// - point-size (length): Diameter of point markers +/// - x-label (none, content): X-axis title +/// - y-label (none, content): Y-axis title +/// - annotations (none, array): Optional annotation descriptors +/// - theme (none, dictionary): Theme overrides +/// - extra-legend-separation (length): Extra space between legend and chart +/// -> content +#let multi-line-chart( + data, + width: auto, + height: auto, + title: none, + show-points: true, + show-legend: true, + line-width: auto, + line-interpolation: "linear", + smooth-radius: 0, + point-size: 6pt, + x-label: none, + y-label: none, + annotations: none, + theme: none, + extra-legend-separation: 0pt +) = context { + layout(size => { + validate-series-data(data, "multi-line-chart") + validate-line-style(line-interpolation, smooth-radius, "multi-line-chart") + let t = _resolve-ctx(theme) + let line-width = if line-width == auto { t.stroke-thick } else { line-width } + let (width, height) = resolve-size(width, height, size, n: data.labels.len(), theme: t) + let (labels, series) = (data.labels, data.series) + let all-values = series.map(s => s.values).flatten() + let inset = _marker-inset(show-points, point-size, line-width) + + cartesian-frame(width, height, t, + x: category-axis(labels, title: x-label, mode: "points", inset: inset), + y: value-axis(calc.min(..all-values), calc.max(..all-values), title: y-label, inset: inset), + title: title, extra-legend-separation: extra-legend-separation, annotations: annotations, + legend: draw-legend-auto(series.map(s => s.name), t, show-legend: show-legend, swatch-type: "line", fit-width: true, max-width: legend-max-width(width, t)), + p => { + let (px, py) = (p.px, p.py) + for (si, s) in series.enumerate() { + let color = get-color(t, si) + let points = s.values.enumerate().map(((i, v)) => (px(i), py(v))) + draw-line-series(points, line-width + color, line-interpolation, smooth-radius) + if show-points { + for pt in points { _dot(pt, point-size, color, t) } + } + } + }, + ) + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/lollipop.typ b/packages/preview/primaviz/0.12.0/src/charts/lollipop.typ new file mode 100644 index 0000000000..aaeb4523f6 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/lollipop.typ @@ -0,0 +1,131 @@ +// lollipop.typ - Lollipop charts (vertical and horizontal) +#import "../theme.typ": _resolve-ctx, get-color +#import "../util.typ": normalize-data +#import "../validate.typ": validate-simple-data +#import "../primitives/frame.typ": cartesian-frame +#import "../primitives/axes.typ": value-axis, category-axis, fit-label-size, value-text +#import "../primitives/layout.typ": resolve-size, pin +#import "../primitives/row-chart.typ": row-chart + +// The value axis of a lollipop chart: always includes the zero baseline. +// `pad` is the room value labels past negative dots need below the axis start. +#let _stem-axis(values, title, pad: 0pt) = { + let (lo, hi) = (calc.min(0, ..values), calc.max(0, ..values)) + value-axis(lo, if lo == hi { 1 } else { hi }, title: title, pad: (lo: if lo < 0 { pad } else { 0pt })) +} + +/// Renders a vertical lollipop chart with a thin stem and circle dot per category. +/// +/// A lollipop chart is like a bar chart but uses a thin line (stem) capped by a +/// filled circle (dot) instead of a filled bar. Useful for ranked or comparison data +/// where a cleaner look is desired. +/// +/// - data (dictionary, array): Label-value pairs as dict or array of tuples +/// - width (length): Chart width +/// - height (length): Chart height +/// - dot-size (length): Diameter of the dot at the end of each stem +/// - stem-width (auto, length): Thickness of the stem line; `auto` uses the theme's `stroke-thick` +/// - title (none, content): Optional chart title +/// - show-values (bool): Display value labels above dots +/// - x-label (none, content): X-axis title +/// - y-label (none, content): Y-axis title +/// - annotations (none, array): Optional annotation descriptors +/// - theme (none, dictionary): Theme overrides +/// -> content +#let lollipop-chart( + data, + width: auto, + height: auto, + dot-size: 8pt, + stem-width: auto, + title: none, + show-values: true, + x-label: none, + y-label: none, + annotations: none, + theme: none, +) = context { + layout(size => { + validate-simple-data(data, "lollipop-chart") + let t = _resolve-ctx(theme) + let stem-width = if stem-width == auto { t.stroke-thick } else { stem-width } + let norm = normalize-data(data) + let (labels, values) = (norm.labels, norm.values) + if values.len() == 0 { return } + let (width, height) = resolve-size(width, height, size, n: values.len(), theme: t) + let strs = values.map(v => value-text(v, t)) + + cartesian-frame(width, height, t, + x: category-axis(labels, title: x-label), + y: _stem-axis(values, y-label, pad: if show-values { 1.2 * t.value-label-size + dot-size / 2 + t.label-offset } else { dot-size / 2 }), + baseline: (y: 0), annotations: annotations, title: title, + reserve: (top: if show-values { t.value-label-size + t.label-offset } else { 0pt }), + p => { + let (px, py) = (p.px, p.py) + let vsize = fit-label-size(strs, p.X.pitch, t.value-label-size) + for (i, val) in values.enumerate() { + let (cx, y-end, color) = (px(i), py(val), get-color(t, i)) + place(left + top, line(start: (cx, py(0)), end: (cx, y-end), stroke: color + stem-width)) + place(left + top, dx: cx - dot-size / 2, dy: y-end - dot-size / 2, + circle(radius: dot-size / 2, fill: color, stroke: none)) + if show-values and vsize != none { + let above = val >= 0 + place(left + top, dx: cx - p.X.pitch / 2, dy: y-end + (if above { -1 } else { 1 }) * (dot-size / 2 + t.label-offset), + pin(width: p.X.pitch, align: center + if above { bottom } else { top }, + text(size: vsize, fill: t.text-color)[#strs.at(i)])) + } + } + }, + ) + }) +} + +/// Renders a horizontal lollipop chart with category labels on the y-axis. +/// +/// Like a horizontal bar chart but uses a thin line (stem) from the y-axis and a +/// filled circle (dot) at the value endpoint. Every row is labelled: `height: auto` +/// grows the chart with the number of rows; a fixed height shrinks the labels to the +/// theme's `category-label-min-size` (5pt unless set) and thins them only below that. +/// +/// - data (dictionary, array): Label-value pairs as dict or array of tuples +/// - width (length): Chart width +/// - height (auto, length): Chart height; `auto` gives every row room for its label +/// - dot-size (length): Diameter of the dot at the end of each stem +/// - stem-width (auto, length): Thickness of the stem line; `auto` uses the theme's `stroke-thick` +/// - title (none, content): Optional chart title +/// - show-values (bool): Display value labels beside dots +/// - x-label (none, content): X-axis title +/// - y-label (none, content): Y-axis title +/// - annotations (none, array): Optional annotation descriptors +/// - theme (none, dictionary): Theme overrides +/// -> content +#let horizontal-lollipop-chart( + data, + width: auto, + height: auto, + dot-size: 8pt, + stem-width: auto, + title: none, + show-values: true, + x-label: none, + y-label: none, + annotations: none, + theme: none, +) = context { + layout(size => { + validate-simple-data(data, "horizontal-lollipop-chart") + let t = _resolve-ctx(theme) + let stem-width = if stem-width == auto { t.stroke-thick } else { stem-width } + let norm = normalize-data(data) + if norm.values.len() == 0 { return } + row-chart(norm.labels, norm.values, width, height, size, t, + title: title, x-label: x-label, y-label: y-label, annotations: annotations, + show-values: show-values, reach: dot-size / 2, + (i, y, x-from, x-to, color, row-h) => { + place(left + top, line(start: (x-from, y), end: (x-to, y), stroke: color + stem-width)) + place(left + top, dx: x-to - dot-size / 2, dy: y - dot-size / 2, + circle(radius: dot-size / 2, fill: color, stroke: none)) + }, + ) + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/metric.typ b/packages/preview/primaviz/0.12.0/src/charts/metric.typ new file mode 100644 index 0000000000..629befbaf3 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/metric.typ @@ -0,0 +1,149 @@ +// metric.typ - Dashboard KPI tiles with big number, label, delta, and optional sparkline +#import "../theme.typ": _resolve-ctx +#import "../util.typ": format-number +#import "../primitives/layout.typ": resolve-size +#import "../primitives/polar-labels.typ": fit-text-size, legible +#import "../primitives/status.typ": status-colors +#import "sparkline.typ": sparkline + +// The text a tile shows for `value` +#let _display-value(value, format, suffix) = { + let shown = if type(value) == str { + value + } else if format != none { + format-number(value, mode: format) + } else { + format-number(value) + } + if suffix != none { shown + suffix } else { shown } +} + +// Largest headline size, from `max-size` down to `min-size`, at which every +// one of `texts` fits `max-w` +#let _fit-value-size(texts, max-w, max-size, min-size) = calc.min( + ..texts.map(s => fit-text-size(sz => text(size: sz, weight: "bold")[#s], calc.max(max-w, 1pt), max-size, min-size))) + +/// Renders a dashboard KPI tile with a prominent value, label, optional delta indicator, +/// and optional trend sparkline. +/// +/// - value (int, float, str): The primary metric value to display +/// - label (str, content): Descriptive label shown below the value +/// - delta (none, int, float): Percentage change; positive renders green with ▲, negative renders red with ▼ +/// - trend (none, array): Array of numeric values rendered as a small sparkline at the bottom +/// - width (length): Card width +/// - height (auto, length): Card height; `auto` sizes to content +/// - format (none, str): Number formatting mode passed to `format-number` ("auto", "si", "comma") +/// - suffix (none, str): Optional suffix appended to the display value (e.g., "%", "ms") +/// - value-size (auto, length): Size of the headline number; `auto` is the theme's, shrunk until the number fits the card +/// - theme (none, dictionary): Theme overrides +/// -> content +#let metric-card( + value: 0, + label: "", + delta: none, + trend: none, + width: 150pt, + height: auto, + format: none, + suffix: none, + value-size: auto, + theme: none, +) = context { + layout(size => { + let t = _resolve-ctx(theme) + set text(size: 11pt) // sized by the theme, not the document font + let width = resolve-size(width, 0pt, size, container: false).width + let card-fill = if t.background != none { t.background } else { white } + let status = status-colors(t) + + let display-value = _display-value(value, format, suffix) + + // The headline number is as big as the theme says, or as big as the card is + // wide (a narrow tile in a row of many would otherwise spill over its neighbours) + let value-size = if value-size != auto { value-size } else { + _fit-value-size((display-value,), width - 24pt, t.title-size * 2, t.axis-label-size) + } + + // Delta colour and symbol; text is darkened until it reads on the card + let delta-content = if delta != none { + let delta-color = if delta > 0 { status.positive } + else if delta == 0 { t.text-color-light } + else { status.negative } + let arrow = if delta > 0 { "▲" } else if delta == 0 { "–" } else { "▼" } + let sign = if delta > 0 { "+" } else { "" } + text(size: t.axis-title-size, fill: legible(delta-color, card-fill, min-ratio: 4.0), weight: "semibold")[#arrow #sign#calc.round(delta, digits: 1)%] + } + + // The trend is a sparkline, coloured by direction + let trend-content = if trend != none and trend.len() >= 2 { + let spark-color = (if trend.last() >= trend.first() { status.positive } else { status.negative }).transparentize(30%) + sparkline(trend, width: width - 24pt, height: 20pt, color: spark-color, fill-area: true, theme: theme) + } + + box( + width: width, + height: height, + fill: card-fill, + stroke: if t.border != none { t.border } else { 0.5pt + t.text-color-light }, + radius: t.border-radius, + inset: 12pt, + )[ + #set align(center) + #stack(dir: ttb, spacing: 6pt, + text(size: value-size, weight: "bold", fill: t.text-color)[#display-value], + text(size: t.axis-title-size, fill: t.text-color-light)[#label], + ..if delta-content != none { (delta-content,) } else { () }, + ..if trend-content != none { (v(3pt), trend-content) } else { () }, + ) + ] + }) +} + +/// Renders multiple metric cards side by side in a responsive row. +/// +/// - metrics (array): Array of dictionaries, each passed as arguments to `metric-card`. +/// Keys: `value`, `label`, and optionally `delta`, `trend`, `format`. +/// - width (length): Total row width +/// - gap (length): Space between cards +/// - theme (none, dictionary): Theme overrides +/// -> content +#let metric-row( + metrics, + width: 100%, + gap: 10pt, + theme: none, +) = context { + let n = metrics.len() + if n == 0 { return } + + layout(size => { + let row-w = resolve-size(width, 0pt, size, container: false).width + let card-w = (row-w - gap * (n - 1)) / n + // One headline size for the whole row: the largest at which every number + // fits its tile, so the tiles match + let t = _resolve-ctx(theme) + let value-size = _fit-value-size( + metrics.map(m => _display-value(m.at("value", default: 0), m.at("format", default: none), m.at("suffix", default: none))), + card-w - 24pt, t.title-size * 2, t.axis-label-size) + let card(m, h) = metric-card( + value-size: value-size, + value: m.at("value", default: 0), + label: m.at("label", default: ""), + delta: m.at("delta", default: none), + trend: m.at("trend", default: none), + width: card-w, + height: h, + format: m.at("format", default: none), + suffix: m.at("suffix", default: none), + theme: theme, + ) + // Cards with and without a delta or trend are different heights: the row + // takes the tallest, so the tiles line up + let tallest = calc.max(..metrics.map(m => measure(card(m, auto)).height)) + block(width: row-w, grid( + columns: range(n).map(_ => card-w), + column-gutter: gap, + ..metrics.map(m => card(m, tallest)), + )) + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/parliament.typ b/packages/preview/primaviz/0.12.0/src/charts/parliament.typ new file mode 100644 index 0000000000..32b6a0830a --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/parliament.typ @@ -0,0 +1,192 @@ +// parliament.typ - Semicircular parliament/seat chart +#import "../theme.typ": _resolve-ctx, get-color +#import "../util.typ": normalize-data +#import "../validate.typ": validate-simple-data +#import "../primitives/container.typ": chart-container +#import "../primitives/legend.typ": draw-legend +#import "../primitives/radial.typ": legend-room, side-legend, split-width +#import "../primitives/layout.typ": resolve-size + +/// Renders a parliament (hemicycle) chart — a semicircle of dots showing seat distribution. +/// +/// Each dot represents one seat. By default the parties take the seats in +/// angular blocks: the first party gets a wedge at the left end of the +/// hemicycle, the next one the wedge after it, and so on over the top to the +/// right end, so a party reads as one block across all rows. Every row keeps +/// its seat count either way. +/// +/// - data (dictionary, array): Label-value pairs where values are seat counts +/// - size (length): Width of the chart area +/// - dot-size (length): Diameter of each seat dot +/// - gap (length): Gap between dots +/// - fill-order (str): How parties take the seats. `"angular"` (default): one wedge per party, left to right. `"rows"`: along the concentric rows from the innermost to the outermost (the layout before wedges became the default), which gives one band per party +/// - title (none, content): Optional chart title +/// - show-legend (bool): Show the party legend (where the theme's `legend-position` puts it) +/// - theme (none, dictionary): Theme overrides +/// -> content +#let parliament-chart( + data, + size: 250pt, + dot-size: 4pt, + gap: 1pt, + fill-order: "angular", + title: none, + show-legend: true, + theme: none, +) = context { + assert(fill-order in ("angular", "rows"), + message: "parliament-chart: fill-order must be \"angular\" or \"rows\", got " + repr(fill-order)) + layout(avail => { + validate-simple-data(data, "parliament-chart") + let t = _resolve-ctx(theme) + let size = resolve-size(size, size, avail).width + let norm = normalize-data(data) + let labels = norm.labels + let values = norm.values + + let total = values.sum() + if total == 0 { + return + } + + // Legend where the theme puts it: beside the hemicycle (which then shrinks + // to leave room), or under/over it + let entries = labels.enumerate().filter(((i, _)) => values.at(i) > 0) + .map(((i, lbl)) => (name: [#lbl (#values.at(i))], color: get-color(t, i))) + let lp = t.legend-position + let legend-on = show-legend and lp != "none" and entries.len() > 0 + let beside = lp == "left" or lp == "right" + // (many parties flow into columns rather than one column far taller than the hemicycle) + let side-lg = if legend-on and beside { + side-legend(entries, t, position: lp, max-height: size * 0.65, max-total: legend-room(size, avail.width, t, margin: 0pt)) + } else { none } + let size = if side-lg != none { split-width(size, 10pt + side-lg.width, avail.width, t).first() } else { size } + + let radius = size / 2 + let dot-r = dot-size / 2 + let spacing = dot-size + gap + + // Determine rows and seat distribution. + // We start from an inner radius and add rows outward until we have enough seats. + // Each row at radius r fits floor(pi * r / spacing) dots along the semicircle. + // If even the tightest packing can't seat everyone, shrink dots and gaps + // together and try again rather than silently dropping seats. + let n-rows = 1 + let row-radii = () + let row-seats = () + let found = false + while not found and spacing > 0.5pt { + let min-inner = spacing * 2 + let max-outer = radius - dot-r + + // Start with 1 row, increase until capacity >= total seats + for try-rows in range(1, 100) { + let step = (max-outer - min-inner) / calc.max(try-rows, 1) + if step < spacing { + // Rows too close, use fewer + break + } + let radii = () + let seats = () + let cap = 0 + for ri in range(try-rows) { + let r = min-inner + ri * step + step / 2 + let n = calc.max(1, int(calc.pi * r / spacing)) + radii.push(r) + seats.push(n) + cap = cap + n + } + row-radii = radii + row-seats = seats + n-rows = try-rows + if cap >= total { + found = true + break + } + } + if not found { + dot-r *= 0.9 + spacing *= 0.9 + } + } + + // If capacity exceeds total, scale down seats per row proportionally + let capacity = row-seats.sum() + if capacity > total { + // Reduce seats proportionally, keeping total exact + let new-seats = () + let assigned = 0 + for (i, s) in row-seats.enumerate() { + if i == row-seats.len() - 1 { + new-seats.push(total - assigned) + } else { + let n = calc.max(1, int(calc.round(s / capacity * total))) + new-seats.push(n) + assigned = assigned + n + } + } + row-seats = new-seats + } + + // Build flat list of seats: position, angle (0 = right end, 180 = left end) and row + let cx = radius + let cy = radius + let seats = () + + for (ri, r) in row-radii.enumerate() { + let n = row-seats.at(ri) + if n <= 0 { continue } + for di in range(n) { + let angle = if n == 1 { 90.0 } else { 180.0 * di / (n - 1) } + let x = cx + r * calc.cos(angle * 1deg) + let y = cy - r * calc.sin(angle * 1deg) + seats.push((x: x, y: y, angle: calc.round(angle, digits: 6), row: ri)) + } + } + + // The order seats are handed out in. "rows" is the layout order (row by row, + // inner to outer); "angular" sweeps the hemicycle from the left end over the + // top to the right end, so each party gets one wedge (ties, seats at the same + // angle in different rows, go inner to outer). + if fill-order == "angular" { + seats = seats.sorted(key: s => (-s.angle, s.row)) + } + + // Assign party colors to seats (fill in order) + let dot-colors = () + let seat-idx = 0 + for (pi, count) in values.enumerate() { + for _ in range(count) { + if seat-idx < seats.len() { + dot-colors.push(get-color(t, pi)) + } + seat-idx = seat-idx + 1 + } + } + let dots = seats.map(s => (s.x, s.y)) + + let chart-height = radius + dot-r + + let lg = if side-lg != none { + side-lg + } else if legend-on { + (legend: draw-legend(entries, t), width: auto, theme: t) + } else { (legend: none, width: 0pt, theme: t) } + + align(center, chart-container(size, chart-height, title, lg.theme, + legend: lg.legend, legend-width: lg.width)[ + #box(width: size, height: chart-height)[ + #for (i, pos) in dots.enumerate() { + if i < dot-colors.len() { + place( + left + top, + dx: pos.at(0) - dot-r, + dy: pos.at(1) - dot-r, + circle(radius: dot-r, fill: dot-colors.at(i), stroke: none) + ) + } + } + ] + ]) + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/pie.typ b/packages/preview/primaviz/0.12.0/src/charts/pie.typ new file mode 100644 index 0000000000..70a7c7740d --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/pie.typ @@ -0,0 +1,158 @@ +// pie.typ - Pie and donut charts +#import "../theme.typ": _resolve-ctx, get-color +#import "../util.typ": normalize-data, nonzero +#import "../primitives/layout.typ": resolve-size +#import "../validate.typ": validate-simple-data +#import "../primitives/container.typ": chart-container, container-inset +#import "../primitives/polar.typ": place-wedge, separator-stroke +#import "../primitives/polar-labels.typ": label-ink, fit-wedge-label, place-centred, place-outside-labels, polar-label-extent +#import "../primitives/radial.typ": legend-left, legend-room, side-legend, split-width + +/// Renders a pie or donut chart from label-value data. +/// +/// Percentages sit inside their slice when they fit (shrinking a little if +/// need be); a slice too small for its label gets it just outside, with a +/// leader line if neighbouring labels push it away. +/// +/// - data (dictionary, array): Label-value pairs as dict or array of tuples +/// - size (length): Diameter of the pie +/// - title (none, content): Optional chart title +/// - show-legend (bool): Show category legend beside the chart +/// - show-percentages (bool): Display percentage labels on slices +/// - donut (bool): Cut out the center to create a donut chart +/// - donut-ratio (float): Inner radius as fraction of outer radius (0 to 1) +/// - subtitle (none, content): Optional subtitle below the title +/// - radius (length): Corner radius of the chart container +/// - theme (none, dictionary): Theme overrides +/// -> content +#let pie-chart( + data, + size: 150pt, + title: none, + show-legend: true, + show-percentages: true, + donut: false, + donut-ratio: 0.4, + subtitle: none, + radius: 0pt, + theme: none, +) = context { + layout(avail => { + validate-simple-data(data, "pie-chart") + let t = _resolve-ctx(theme) + let size = resolve-size(size, size, avail).width + let norm = normalize-data(data) + let (labels, values) = (norm.labels, norm.values) + let total = nonzero(values.sum()) + let pcts = values.map(v => calc.round(v / total * 100, digits: 1)) + + // Each slice's angular span, resolved once for wedges and labels alike. + // Slices run clockwise from 12 o'clock (-90 in polar degrees, 0 = 3 o'clock). + let slices = { + let out = () + let cur = -90 + for (i, v) in values.enumerate() { + let span = v / total * 360 + out.push((i: i, start: cur, span: span, mid: cur + span / 2, pct: pcts.at(i))) + cur += span + } + out + } + + // Legend column; the pie shrinks first if the container is narrow + let legend-entries = labels.enumerate().map(((i, lbl)) => [#lbl (#pcts.at(i)%)]) + let legend-on = show-legend and t.legend-position != "none" + // (dozens of entries flow into columns instead of one column taller than the pie) + let lg = side-legend(legend-entries, t, enabled: legend-on, max-height: size * 1.3, max-total: legend-room(size, avail.width, t)) + let legend-side = if lg.legend != none { 10pt + lg.width } else { 0pt } + let (size, side) = split-width(size, legend-side, avail.width, t) + let lg-widths = (:) + if side != legend-side { + lg-widths = (min-width: side - 10pt, max-width: side - 10pt) + lg = side-legend(legend-entries, t, enabled: legend-on, ..lg-widths, max-height: size * 1.3, max-total: side - 10pt) + } + + // Percentage labels: inside the slice when they fit, outside when not + let label-text(s) = if s.pct > 0 and s.pct < 0.1 { "<0.1%" } else { str(s.pct) + "%" } + let r-hole = if donut { donut-ratio } else { 0 } + let plan(pie-r) = { + let inside = () + let outside = () + if show-percentages { + for s in slices.filter(s => s.span > 0) { + let ink = label-ink(get-color(t, s.i), t) + let make(sz) = text(size: sz, weight: "bold", fill: ink, top-edge: "cap-height", bottom-edge: "baseline")[#label-text(s)] + let r-in = pie-r * r-hole + let fit = fit-wedge-label(make, s.mid, s.span / 2, r-in, pie-r, t.value-label-size, t.axis-label-size, + r-pref: if donut { (r-in + pie-r) / 2 } else { pie-r * 0.7 }) + if fit != none { + inside.push((s: s, fit: fit, body: make(fit.size))) + } else { + outside.push((angle: s.mid, priority: s.span, + body: text(size: t.value-label-size, weight: "bold", fill: t.text-color, top-edge: "cap-height", bottom-edge: "baseline")[#label-text(s)])) + } + } + } + // Only the larger of the small slices get an outside label: a dozen of + // them would shrink the pie to make room, and the legend lists every + // percentage anyway + let cap = if legend-on { 8 } else { 14 } + outside = outside.sorted(key: o => -o.priority).slice(0, calc.min(outside.len(), cap)) + // Room the outside labels need around the pie + let ms = outside.map(o => measure(o.body)) + let mx = if outside.len() > 0 { calc.max(..ms.map(m => m.width)) + 12pt } else { 0pt } + // Above / below the pie only where a label actually sits there + let my-h = if ms.len() > 0 { calc.max(..ms.map(m => m.height)) + 8pt } else { 0pt } + let my-top = if outside.any(o => calc.sin(o.angle * 1deg) < -0.5) { my-h } else { 0pt } + let my-bot = if outside.any(o => calc.sin(o.angle * 1deg) > 0.5) { my-h } else { 0pt } + // Labels of a crowd are fanned along the circle, which can take them + // somewhere other than where they started: make room for where they land + if outside.len() > 0 { + let ext = polar-label-extent(outside, pie-r, gap: 4pt) + mx = calc.max(mx, ext.x - pie-r + 3pt) + if ext.top > pie-r + 0.5pt { my-top = calc.max(my-top, ext.top - pie-r + 4pt) } + if ext.bottom > pie-r + 0.5pt { my-bot = calc.max(my-bot, ext.bottom - pie-r + 4pt) } + } + (inside: inside, outside: outside, mx: mx, my-top: my-top, my-bot: my-bot) + } + let pl = plan(size / 2) + if pl.mx > 0pt { + let (shrunk, _) = split-width(size, side + 2 * pl.mx, avail.width, t) + if shrunk < size { + size = shrunk + pl = plan(size / 2) + } + } + + let (box-w, box-h) = (size + 2 * pl.mx, size + pl.my-top + pl.my-bot) + // A legend flowed into columns before the labels were known: fewer columns + // if the chart and its labels left less room than that + if lg.cols > 1 and box-w + 10pt + lg.width > avail.width - 2 * t.at("container-inset", default: container-inset) { + lg = side-legend(legend-entries, t, enabled: legend-on, ..lg-widths, max-height: size * 1.3, max-total: legend-left(box-w, avail.width, t)) + } + let (cx, cy) = (box-w / 2, pl.my-top + size / 2) + let pie-r = size / 2 + // A legend far taller than the pie (dozens of entries) starts level with + // the pie's top instead of leaving it floating mid-column + let body-h = if lg.height > 1.5 * box-h { lg.height } else { box-h } + + align(center, chart-container(box-w, body-h, title, lg.theme, legend: lg.legend, legend-width: lg.width, subtitle: subtitle, radius: radius)[ + #box(width: box-w, height: body-h)[ + // Wedges first, then every label on top + #if values.all(v => v == 0) { + place(left + top, dx: cx - pie-r, dy: cy - pie-r, circle(radius: pie-r, stroke: t.grid-stroke)) + } + #for s in slices { + place-wedge(cx, cy, pie-r * r-hole, pie-r, s.start, s.start + s.span, get-color(t, s.i), + stroke: separator-stroke(t, thickness: 1pt)) + } + #for l in pl.inside { + let (x, y) = (cx + l.fit.r * calc.cos(l.s.mid * 1deg), cy + l.fit.r * calc.sin(l.s.mid * 1deg)) + place-centred(x, y, l.fit.w, l.fit.h, l.body) + } + #place-outside-labels(pl.outside, cx, cy, pie-r, (left: 0pt, right: box-w, top: 0pt, bottom: box-h), + leader-stroke: 0.5pt + t.text-color-light) + ] + ]) + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/radar.typ b/packages/preview/primaviz/0.12.0/src/charts/radar.typ new file mode 100644 index 0000000000..a574ac8003 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/radar.typ @@ -0,0 +1,142 @@ +// radar.typ - Radar/spider charts +#import "../theme.typ": _resolve-ctx, get-color +#import "../util.typ": nonzero +#import "../validate.typ": validate-series-data +#import "../primitives/container.typ": chart-container +#import "../primitives/layout.typ": resolve-size +#import "../primitives/polar.typ": polar-pt +#import "../primitives/polar-labels.typ": legible, polar-fit-radius, polar-label-extent, place-outside-labels, wrap-label +#import "../primitives/radial.typ": side-legend, split-width + +/// Renders a radar (spider) chart for comparing series across multiple axes. +/// +/// The plot is as large as the axis labels allow: they are measured, and sit +/// just beyond their spoke without touching the polygon, the scale or each +/// other. With one series, each axis label carries that series' value. +/// +/// - data (dictionary): Dict with `labels` (axis names) and `series` (each with `name` and `values`) +/// - size (length): Diameter of the radar chart +/// - axis-max-value (none, number): Fixed maximum for every axis; `none` uses the largest data value +/// - title (none, content): Optional chart title +/// - show-legend (bool): Show series legend +/// - show-value-labels (bool): Display scale values and data point labels +/// - fill-opacity (ratio): Opacity of the filled data area +/// - theme (none, dictionary): Theme overrides +/// - extra-legend-separation (length): Extra space between legend and chart +/// -> content +#let radar-chart( + data, + size: 200pt, + axis-max-value: none, + title: none, + show-legend: true, + show-value-labels: true, + fill-opacity: 15%, + theme: none, + extra-legend-separation: 0pt +) = context { + layout(avail => { + validate-series-data(data, "radar-chart") + let t = _resolve-ctx(theme) + let size = resolve-size(size, size, avail).width + let labels = data.labels + let series = data.series + let n = labels.len() + let single = series.len() == 1 + + let all-values = series.map(s => s.values).flatten() + let max-val = nonzero(if axis-max-value == none { calc.max(..all-values) } else { axis-max-value }) + let bg = if t.background != none { t.background } else { white } + + // Legend column (several series only); the chart shrinks first if narrow + let lg = side-legend(series.map(s => s.name), t, + enabled: show-legend and t.legend-position != "none" and series.len() > 1, max-width: 120pt) + let legend-side = if lg.legend != none { 10pt + lg.width + extra-legend-separation } else { 0pt } + let (size, _) = split-width(size, legend-side, avail.width, t) + let (cx, cy) = (size / 2, size / 2) + + // Axis labels: the name, and with one series, its value under it + // (a long name wraps instead of shrinking the plot) + let angle-of(i) = -90 + i / n * 360 + let axis-items-at(label-size) = labels.enumerate().map(((i, lbl)) => { + let name = text(size: label-size, fill: t.text-color, top-edge: "cap-height", bottom-edge: "descender")[#lbl] + let name = wrap-label(name, calc.max(60pt, size * 0.3), angle-of(i)) + let body = if single and show-value-labels { + let v = text(size: label-size, weight: "bold", fill: legible(get-color(t, 0), bg), + top-edge: "cap-height", bottom-edge: "descender")[#series.at(0).values.at(i)] + stack(dir: ttb, spacing: 2pt, name, v) + } else { name } + (angle: angle-of(i), body: body) + }) + // The largest type that still leaves the plot most of the box (a small + // chart gets smaller labels rather than a small plot), down to the theme's floor + let floor-size = t.at("category-label-min-size", default: 5pt) + let sizes = (if n > 12 { () } else { (t.legend-size,) }) + (t.axis-label-size, t.axis-label-size * 0.85, floor-size) + let sizes = sizes.filter(sz => sz >= floor-size).dedup() + let (axis-items, radius, label-size) = { + let best = none + for sz in sizes { + let items = axis-items-at(sz) + let r = polar-fit-radius(items, cx, cy, size, size, gap: 5pt, pad: 1pt) + if best == none or r > best.at(1) { best = (items, r, sz) } + if r >= 0.3 * size { break } + } + best + } + // The scale is read after the axis names, never in larger type + let scale-size = calc.min(t.axis-label-size, label-size) + // The labels may leave the plot wider than tall: trim the empty height + let box-h = calc.min(size, 2 * polar-label-extent(axis-items, radius, gap: 5pt).y) + let cy = box-h / 2 + + // Scale labels: fractions of the maximum, in the fewest digits that tell them apart + let scale-label(level) = { + let v = max-val * level / 4 + str(calc.round(v, digits: if max-val >= 20 { 0 } else if max-val >= 2 { 1 } else { 2 })) + } + + align(center, chart-container(size, box-h, title, lg.theme, + legend: lg.legend, legend-width: lg.width, extra-legend-separation: extra-legend-separation)[ + #box(width: size, height: box-h)[ + // Grid rings + #for level in array.range(1, 5) { + let r = radius * level / 4 + place(left + top, polygon(fill: none, stroke: t.grid-stroke, + ..array.range(n).map(i => polar-pt(cx, cy, r, angle-of(i))))) + } + // Spokes + #for i in array.range(n) { + place(left + top, line(start: (cx, cy), end: polar-pt(cx, cy, radius, angle-of(i)), stroke: t.grid-stroke)) + } + // Data + #for (si, s) in series.enumerate() { + let color = get-color(t, si) + let pts = s.values.enumerate().map(((i, val)) => polar-pt(cx, cy, val / max-val * radius, angle-of(i))) + let dot-r = calc.max(1pt, size / 100) + place(left + top, polygon(fill: color.transparentize(100% - fill-opacity), + stroke: color + calc.max(0.4pt, size / 250), ..pts)) + for pt in pts { + place(left + top, dx: pt.at(0) - dot-r, dy: pt.at(1) - dot-r, + circle(radius: dot-r, fill: color, stroke: t.marker-stroke)) + } + } + + // Scale, hanging just under each ring beside the first spoke, over the + // data on a page-coloured plate so a series line never strikes a number + #if show-value-labels { + // (every other ring when four would touch) + let step = if radius / 4 >= scale-size * 1.4 { 1 } else { 2 } + for level in array.range(1, 5).filter(l => calc.rem(4 - l, step) == 0) { + place(left + top, dx: cx + 0.5pt, dy: cy - radius * level / 4 + 0.5pt, + box(fill: bg.transparentize(10%), inset: (x: 1.5pt, y: 0.5pt), radius: 1pt, + text(size: scale-size, fill: t.text-color-light, top-edge: "cap-height", bottom-edge: "baseline")[#scale-label(level)])) + } + } + + // Axis labels last, on top + #place-outside-labels(axis-items, cx, cy, radius, (left: 0pt, right: size, top: 0pt, bottom: box-h), gap: 5pt, + leader-stroke: 0.5pt + t.text-color-light) + ] + ]) + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/radial-bar.typ b/packages/preview/primaviz/0.12.0/src/charts/radial-bar.typ new file mode 100644 index 0000000000..393eaf976e --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/radial-bar.typ @@ -0,0 +1,115 @@ +// radial-bar.typ - Radial bar chart (bars radiating outward from center in a circle) +#import "../theme.typ": _resolve-ctx, get-color +#import "../util.typ": normalize-data, nice-ticks, clamp +#import "../validate.typ": validate-simple-data +#import "../primitives/container.typ": chart-container, container-inset +#import "../primitives/polar.typ": place-wedge, separator-stroke +#import "../primitives/polar-labels.typ": place-centred, place-outside-labels, polar-fit-radius, polar-label-extent, wrap-label +#import "../primitives/layout.typ": resolve-size + +/// Renders a radial bar chart where each category occupies an equal angular +/// slice of a circle and bar length (radius) is proportional to value. +/// +/// Concentric hairlines mark a value scale (labelled at 12 o'clock), and each +/// bar's name sits just beyond its own tip. With `show-values` the value is +/// printed under the name. +/// +/// - data (dictionary, array): Label-value pairs as dict or array of tuples +/// - size (length): Diameter of the chart +/// - inner-radius (float): Inner hole as a fraction of the total radius (0.0 to 1.0) +/// - title (none, content): Optional chart title +/// - show-labels (bool): Display category labels beside the bar tips +/// - show-values (bool): Display numeric values at the bar tips +/// - gap (float): Angular gap between bars in degrees +/// - theme (none, dictionary): Theme overrides +/// -> content +#let radial-bar-chart( + data, + size: 250pt, + inner-radius: 0.3, + title: none, + show-labels: true, + show-values: false, + gap: 2, + theme: none, +) = context { + layout(avail => { + validate-simple-data(data, "radial-bar-chart") + let t = _resolve-ctx(theme) + let size = resolve-size(size, size, avail).width + let norm = normalize-data(data) + let (labels, values) = (norm.labels, norm.values) + let n = values.len() + if n == 0 { return } + + // A nice scale, so the hairlines fall on round values + let scale = if calc.max(..values, 0) > 0 { nice-ticks(0, calc.max(..values), count: 4) } else { (ticks: (0, 1), max: 1) } + let scale-max = scale.max + let slice-deg = 360 / n + let bg = if t.background != none { t.background } else { white } + let mid-of(i) = -90 + (i + 0.5) * slice-deg + let frac-of(v) = clamp(v / scale-max, 0, 1) + let value-text(v) = str(calc.round(v, digits: 1)) + + // Name (and value) beside each tip. How far out a tip is depends on the + // radius, so the labels are first measured, then the circle is sized. + // With many bars a label at its own tip would land on its neighbours: past + // ten they sit outside the circle, like a polar axis, and in smaller type + let at-tip = n <= 10 + let label-size = if n > 16 { t.axis-label-size } else { t.legend-size } + let tip-body(i) = { + let parts = () + if show-labels { + parts.push(wrap-label(text(size: label-size, fill: t.text-color, top-edge: "cap-height", bottom-edge: "descender")[#labels.at(i)], + calc.max(60pt, size * 0.4), mid-of(i))) + } + if show-values { parts.push(text(size: label-size, weight: "bold", fill: t.text-color, top-edge: "cap-height", bottom-edge: "baseline")[#value-text(values.at(i))]) } + stack(dir: ttb, spacing: 2pt, ..parts) + } + let wanted = show-labels or show-values + let measured = if wanted { array.range(n).map(i => (angle: mid-of(i), body: tip-body(i))) } else { () } + let label-gap = 4pt + + let r-out = size / 2 + let ext = polar-label-extent(measured, r-out, gap: label-gap) + let avail-w = avail.width - 2 * t.at("container-inset", default: container-inset) + if type(avail.width) == length and avail.width > 0pt and 2 * ext.x > avail-w { + r-out = polar-fit-radius(measured, avail-w / 2, avail-w / 2, avail-w, avail-w, gap: label-gap) + ext = polar-label-extent(measured, r-out, gap: label-gap) + } + let (box-w, box-h) = (2 * ext.x, 2 * ext.y) + let (cx, cy) = (box-w / 2, box-h / 2) + let r-in = r-out * inner-radius + let radius-of(v) = r-in + (r-out - r-in) * frac-of(v) + + align(center, chart-container(box-w, box-h, title, t)[ + #box(width: box-w, height: box-h)[ + // Scale: a hairline circle per tick, the first being the baseline + #for v in scale.ticks { + let rr = radius-of(v) + place(left + top, dx: cx - rr, dy: cy - rr, circle(radius: rr, stroke: t.grid-stroke)) + } + + // Bars + #for (i, v) in values.enumerate() { + place-wedge(cx, cy, r-in, radius-of(v), mid-of(i) - slice-deg / 2 + gap / 2, mid-of(i) + slice-deg / 2 - gap / 2, + get-color(t, i), stroke: separator-stroke(t)) + } + + // Scale values, at 12 o'clock just inside their circle, on the page colour + #for v in scale.ticks.filter(v => v > 0 and v <= scale-max) { + let label = box(fill: bg, inset: (x: 1.5pt, y: 0.5pt), radius: 1pt, + text(size: t.axis-label-size, fill: t.text-color-light, top-edge: "cap-height", bottom-edge: "baseline")[#v]) + let m = measure(label) + place-centred(cx, cy - radius-of(v) + m.height / 2 + 1pt, m.width, m.height, label) + } + + // Names (and values) beside the tips + #place-outside-labels( + measured.enumerate().map(((i, it)) => (..it, r: if at-tip { radius-of(values.at(i)) } else { r-out }, priority: values.at(i))), + cx, cy, r-out, (left: 0pt, right: box-w, top: 0pt, bottom: box-h), + gap: label-gap, leader-stroke: 0.5pt + t.text-color-light) + ] + ]) + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/rings.typ b/packages/preview/primaviz/0.12.0/src/charts/rings.typ new file mode 100644 index 0000000000..2f7cc3e62b --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/rings.typ @@ -0,0 +1,95 @@ +// rings.typ - Donut progress / fitness rings chart (Apple Watch style) +#import "../theme.typ": _resolve-ctx, get-color +#import "../util.typ": nonzero +#import "../validate.typ": validate-ring-data +#import "../primitives/container.typ": chart-container +#import "../primitives/layout.typ": resolve-size +#import "../primitives/polar.typ": place-arc-band, polar-pt +#import "../primitives/radial.typ": split-width + +/// Concentric ring progress chart (fitness rings). +/// Outermost ring = first entry, innermost = last. +/// Rings that exceed 100 % wrap around (overlap), the head of the second +/// lap marked with a thin outline. +/// +/// - entries (array): Array of dicts with `name`, `value`, and `max` +/// - size (length): Outer diameter of the ring stack +/// - title (none, content): Optional chart title +/// - ring-width (length): Stroke thickness of each ring +/// - gap (length): Gap between concentric rings +/// - show-labels (bool): Display entry names beside the rings +/// - show-values (bool): Display each entry's value and max +/// - theme (none, dictionary): Theme overrides +/// -> content +#let ring-progress( + entries, // Array of dicts: ((name: "Move", value: 75, max: 100), ...) + size: 150pt, + title: none, + ring-width: 12pt, + gap: 4pt, + show-labels: true, + show-values: true, + theme: none, +) = context { + layout(avail => { + validate-ring-data(entries, "ring-progress") + let t = _resolve-ctx(theme) + let size = resolve-size(size, size, avail).width + let n = entries.len() + let colors = array.range(n).map(i => get-color(t, i)) + let bg = if t.background != none { t.background } else { white } + let pct(e) = calc.round(e.value / nonzero(e.max) * 100, digits: 0) + + // Label column: swatch, name, and value / max under it + let sw = t.legend-swatch-size + let name-text(e) = text(size: t.legend-size, weight: "bold", fill: t.text-color)[#e.name] + let value-text(e) = text(size: t.value-label-size, fill: t.text-color-light)[#e.value / #e.max (#pct(e)%)] + let col-w = if show-labels { + sw + 6pt + 2pt + calc.max(..entries.map(e => calc.max(measure(name-text(e)).width, if show-values { measure(value-text(e)).width } else { 0pt }))) + } else { 0pt } + let (size, side) = split-width(size, if show-labels { 14pt + col-w } else { 0pt }, avail.width, t) + let col-w = if show-labels { side - 14pt } else { 0pt } + let total-w = size + side + + // Too many rings for the radius: thinner rings, not missing ones + let span = n * ring-width + (n - 1) * gap + let fit = calc.min(1, (size / 2 * 0.85) / span) + let (ring-width, gap) = (ring-width * fit, gap * fit) + + let label-col = if show-labels { + grid(columns: (sw, col-w - sw - 6pt), column-gutter: 6pt, row-gutter: 8pt, align: left + horizon, + ..entries.enumerate().map(((i, e)) => ( + box(width: sw, height: sw, fill: colors.at(i), radius: 2pt), + stack(dir: ttb, spacing: 2pt, name-text(e), if show-values { value-text(e) }), + )).flatten()) + } + // Many entries make the label column taller than the rings: the box grows + // to hold it and the rings stay centred + let label-h = if show-labels { measure(label-col).height } else { 0pt } + let body-h = calc.max(size, label-h) + let (cx, cy) = (size / 2, body-h / 2) + + align(center, chart-container(total-w, body-h, title, t)[ + #box(width: total-w, height: body-h)[ + #for (i, e) in entries.enumerate() { + let r = size / 2 - ring-width / 2 - i * (ring-width + gap) + let progress = e.value / nonzero(e.max) + // The ring's track is its colour, faded into the background + place-arc-band(cx, cy, r, ring-width, -90, 270, color.mix((colors.at(i), 20%), (bg, 80%))) + if progress > 0 { + place-arc-band(cx, cy, r, ring-width, -90, -90 + calc.min(progress, 1) * 360, colors.at(i), round: true) + } + if progress > 1 { + // Second lap: the head rides over the first, so it needs an edge + let (hx, hy) = polar-pt(cx, cy, r, -90 + calc.min(progress - 1, 1) * 360) + place(left + top, dx: hx - ring-width / 2, dy: hy - ring-width / 2, + circle(radius: ring-width / 2, fill: colors.at(i), stroke: 1pt + bg)) + } + } + #if show-labels { + place(left + top, dx: size + 14pt, dy: (body-h - label-h) / 2, box(width: col-w, height: label-h, label-col)) + } + ] + ]) + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/sankey.typ b/packages/preview/primaviz/0.12.0/src/charts/sankey.typ new file mode 100644 index 0000000000..1f3a9a6a4b --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/sankey.typ @@ -0,0 +1,249 @@ +// sankey.typ - Sankey / flow diagrams +#import "../theme.typ": _resolve-ctx, get-color +#import "../util.typ": nonzero, format-number +#import "../validate.typ": validate-sankey-data +#import "../primitives/container.typ": chart-container +#import "../primitives/layout.typ": resolve-size, text-width, place-centered, spread-labels, fit-font-size + +/// Renders a Sankey (flow) diagram showing quantities flowing between nodes. +/// +/// Nodes are arranged in columns (layers) determined by topological ordering. +/// Flows are drawn as curved bands connecting source and target node ports. +/// +/// - data (dictionary): Must contain `nodes` (array of strings) and `flows` +/// (array of dicts with `from`, `to`, `value` keys — indices into `nodes`). +/// - width (length): Chart width +/// - height (length): Chart height +/// - title (none, content): Optional chart title +/// - node-width (length): Width of each node rectangle +/// - show-labels (bool): Display node name labels +/// - show-values (bool): Display flow value labels on nodes +/// - theme (none, dictionary): Theme overrides +/// -> content +#let sankey-chart( + data, + width: auto, + height: auto, + title: none, + node-width: 15pt, + show-labels: true, + show-values: false, + theme: none, +) = context { + layout(size => { + validate-sankey-data(data, "sankey-chart") + let t = _resolve-ctx(theme) + let (width, height) = resolve-size(width, height, size, n: data.nodes.len(), theme: t) + + let nodes = data.nodes + let flows = data.flows + let n = nodes.len() + + // ── Build adjacency info ────────────────────────────────────────────── + // Compute total in/out flow per node + let node-out = array.range(n).map(_ => 0) + let node-in = array.range(n).map(_ => 0) + for f in flows { + node-out.at(f.from) = node-out.at(f.from) + f.value + node-in.at(f.to) = node-in.at(f.to) + f.value + } + // Node total value = max(in, out) — handles source/sink nodes + let node-value = array.range(n).map(i => calc.max(node-out.at(i), node-in.at(i))) + + // ── Assign layers via longest-path from sources ─────────────────────── + let layer = array.range(n).map(_ => 0) + // Iterative relaxation (safe for DAGs of any depth) + let changed = true + let iters = 0 + while changed and iters < n { + changed = false + iters += 1 + for f in flows { + let new-layer = layer.at(f.from) + 1 + if new-layer > layer.at(f.to) { + layer.at(f.to) = new-layer + changed = true + } + } + } + + let max-layer = nonzero(calc.max(..layer)) + let num-layers = max-layer + 1 + + // Group nodes by layer + let layers = array.range(num-layers).map(_ => ()) + for i in array.range(n) { + layers.at(layer.at(i)).push(i) + } + + // ── Layout geometry ─────────────────────────────────────────────────── + // Node labels: [name (value)]; the outer columns keep theirs in a gutter + // measured from the labels (first column on the left, last on the right) + let label-size = calc.min(t.axis-label-size, calc.max(5pt, height / 25)) + let label-of(i) = if show-values { [#nodes.at(i) (#format-number(node-value.at(i)))] } else { [#nodes.at(i)] } + let label-gap = 4pt + let gutter-cap = width * 0.28 + let first-col = layers.first() + let last-col = if num-layers > 1 { layers.last() } else { () } + let widest(col) = col.fold(0pt, (acc, i) => calc.max(acc, text-width(label-of(i), label-size))) + let gutter(col) = if not show-labels or col.len() == 0 { 0pt } else { + calc.min(widest(col) + label-gap, gutter-cap) + } + let pad-left = calc.max(8pt, gutter(first-col)) + let pad-right = calc.max(8pt, gutter(last-col)) + let pad-y = 10pt // vertical padding top/bottom + let chart-w = width - pad-left - pad-right + let chart-h = height - 2 * pad-y + + // Column x-positions (left edge of node rect) + let col-spacing = if num-layers > 1 { + (chart-w - node-width) / (num-layers - 1) + } else { + 0pt + } + + // Compute vertical positions for each node within its layer. + // One scale (length per unit of value) for the whole chart, set by the + // fullest column, so a flow keeps the same thickness from end to end and + // a node's bands never stretch to fill it. Shorter columns are centred. + let node-gap = calc.max(4pt, chart-h * 0.03) + let node-x = array.range(n).map(_ => 0pt) + let node-y = array.range(n).map(_ => 0pt) + let node-h = array.range(n).map(_ => 0pt) + + let col-avail = layers.map(col => calc.max(10pt, chart-h - node-gap * calc.max(col.len() - 1, 0))) + let col-total = layers.map(col => col.fold(0, (acc, i) => acc + node-value.at(i))) + let scales = array.range(num-layers) + .filter(li => col-total.at(li) > 0) + .map(li => col-avail.at(li) / col-total.at(li)) + let scale = if scales.len() > 0 { calc.min(..scales) } else { 0pt } + + for li in array.range(num-layers) { + let col-nodes = layers.at(li) + let hs = col-nodes.map(i => node-value.at(i) * scale) + let col-h = hs.sum(default: 0pt) + node-gap * calc.max(col-nodes.len() - 1, 0) + let y-cursor = pad-y + (chart-h - col-h) / 2 + for (k, i) in col-nodes.enumerate() { + node-x.at(i) = pad-left + col-spacing * layer.at(i) + node-y.at(i) = y-cursor + node-h.at(i) = hs.at(k) + y-cursor = y-cursor + hs.at(k) + node-gap + } + } + + // ── Sort flows per node to get stable port offsets ──────────────────── + // For each node, track how much of its height has been "used" by flows + // on the outgoing (right) and incoming (left) side. + let out-offset = array.range(n).map(_ => 0pt) + let in-offset = array.range(n).map(_ => 0pt) + + // Sort flows by source-layer then target-layer for visual consistency + let sorted-flows = flows.sorted(key: f => layer.at(f.from) * 1000 + layer.at(f.to)) + + // ── Render ──────────────────────────────────────────────────────────── + let page-bg = if t.background == none { white } else { t.background } + align(center, chart-container(width, height, title, t)[ + #box(width: width, height: height)[ + // Draw flows first (behind nodes) + #for f in sorted-flows { + let src = f.from + let dst = f.to + let val = f.value + // Same scale as the nodes: the band is equally thick at both ends + let src-h = val * scale + let dst-h = val * scale + + let src-y-start = node-y.at(src) + out-offset.at(src) + let dst-y-start = node-y.at(dst) + in-offset.at(dst) + + // Update offsets + out-offset.at(src) = out-offset.at(src) + src-h + in-offset.at(dst) = in-offset.at(dst) + dst-h + + // Source right edge, destination left edge + let x0 = node-x.at(src) + node-width + let x1 = node-x.at(dst) + + // Build a curved polygon with ~20 intermediate steps + let steps = 20 + let top-points = () + let bot-points = () + for s in array.range(steps + 1) { + let frac = s / steps + // Cubic ease in-out: 3t^2 - 2t^3 + let ease = 3 * calc.pow(frac, 2) - 2 * calc.pow(frac, 3) + let x = x0 + (x1 - x0) * frac + let y-top = src-y-start + (dst-y-start - src-y-start) * ease + let y-bot = (src-y-start + src-h) + ((dst-y-start + dst-h) - (src-y-start + src-h)) * ease + top-points.push((x, y-top)) + bot-points.push((x, y-bot)) + } + // Polygon: top edge left→right, then bottom edge right→left + let pts = top-points + bot-points.rev() + + let flow-color = get-color(t, src) + place(left + top, polygon( + fill: flow-color.transparentize(60%), + stroke: none, + ..pts, + )) + } + + // Draw nodes + #for i in array.range(n) { + let color = get-color(t, i) + place(left + top, + dx: node-x.at(i), + dy: node-y.at(i), + rect( + width: node-width, + height: node-h.at(i), + fill: color, + stroke: 0.5pt + color.darken(20%), + ), + ) + } + + // Draw labels — spread apart per layer so none overlaps another. First + // and middle columns put theirs left of the node, the last column to its + // right; each is wrapped at the room it has, never clipped. + #if show-labels { + for li in array.range(num-layers) { + let col-nodes = layers.at(li) + let is-last = num-layers > 1 and li == num-layers - 1 + let avail = if is-last { pad-right - label-gap } else if li == 0 { pad-left - label-gap } else { + calc.max(20pt, col-spacing - node-width - 2 * label-gap) + } + let sizes = col-nodes.map(i => fit-font-size(label-of(i), avail, label-size, min: 6pt)) + let heights = col-nodes.enumerate().map(((k, i)) => + measure(box(width: avail, text(size: sizes.at(k))[#label-of(i)])).height) + let sep = heights.fold(0pt, calc.max) + 1pt + // The names of the biggest nodes win when a column has more than it holds + let ys = spread-labels( + col-nodes.map(i => node-y.at(i) + node-h.at(i) / 2), sizes: sep, + lo: pad-y, hi: height - pad-y, priority: col-nodes.map(i => node-value.at(i))) + for (k, i) in col-nodes.enumerate() { + if ys.at(k) == none { continue } + let ink = text(size: sizes.at(k), fill: t.text-color)[#label-of(i)] + // Labels of middle columns sit on the flows: a halo in the page + // colour keeps them legible on any band + let body = if li > 0 and not is-last { + let halo = text(size: sizes.at(k), fill: page-bg, + stroke: (paint: page-bg, thickness: 0.35 * sizes.at(k), join: "round"))[#label-of(i)] + box(width: avail, { + place(top + left, box(width: avail, align(right, halo))) + align(right, ink) + }) + } else { ink } + if is-last { + place-centered(node-x.at(i) + node-width + label-gap, ys.at(k), body, width: avail, align: left) + } else { + place-centered(node-x.at(i) - label-gap - avail, ys.at(k), body, width: avail, align: right) + } + } + } + } + ] + ]) + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/scatter.typ b/packages/preview/primaviz/0.12.0/src/charts/scatter.typ new file mode 100644 index 0000000000..b26f12567d --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/scatter.typ @@ -0,0 +1,375 @@ +// scatter.typ - Scatter plot and bubble chart +#import "../theme.typ": _resolve-ctx, get-color +#import "../util.typ": nonzero, label-len +#import "../primitives/layout.typ": try-fit-label, resolve-size +#import "../validate.typ": validate-scatter-data, validate-multi-scatter-data, validate-bubble-data, validate-multi-bubble-data +#import "../primitives/frame.typ": cartesian-frame +#import "../primitives/axes.typ": value-axis +#import "../primitives/legend.typ": draw-legend-auto, draw-size-legend, legend-max-width + +// Both axes of an x-y chart. Small marks keep `inset` free at the ends of each +// axis, so a mark centred on an extreme value stays inside the plot. With `radii` +// (one per point: bubbles) the axes instead widen to the next nice tick until every +// bubble lies inside the plot, so the axis line, the ticks and the plot agree. +#let _xy-axes(points, inset, x-label, y-label, radii: none) = { + let xs = points.map(p => p.at(0)) + let ys = points.map(p => p.at(1)) + let marks(i) = if radii == none { () } else { points.zip(radii).map(((pt, r)) => (pt.at(i), r)) } + let inset = if radii == none { inset } else { 0pt } + ( + x: value-axis(calc.min(..xs), calc.max(..xs), title: x-label, inset: inset, marks: marks(0)), + y: value-axis(calc.min(..ys), calc.max(..ys), title: y-label, inset: inset, marks: marks(1)), + ) +} + +// Resolves the theme with the chart's own `show-grid` override applied. +#let _scatter-theme(theme, show-grid) = _resolve-ctx(theme, overrides: if show-grid != auto { (show-grid: show-grid) } else { none }) + +// Radius bubbles are drawn at: the largest bubble never takes over the plot (its +// diameter stays under about 36% of the plot height, `height` less the room the +// axes take, and 24% of the width). +#let _bubble-radii(max-radius, min-radius, width, height, t) = { + let hi = calc.min(max-radius, (height - 6 * t.axis-label-size) * 0.18, width * 0.12) + (hi: hi, lo: calc.min(min-radius, hi * 0.5)) +} + +// Whether two `(x, y, w, h)` boxes overlap. +#let _hit(a, b) = a.x < b.x + b.w and b.x < a.x + a.w and a.y < b.y + b.h and b.y < a.y + a.h + +// Spot for a label of `w` x `h` beside bubble `b`: the nearest of eight directions, +// tried at three distances, that stays in `bounds`, clear of the `taken` label boxes +// and clear of the other bubbles (`all`). When none is clear, the least bad one. +#let _label-spot(b, all, taken, bounds, w, h) = { + let gap = 2pt + let spots = () + for (k, off) in (0pt, h * 1.1, h * 2.2).enumerate() { + let d = b.radius + gap + off + let e = d * 0.71 + for (i, (x, y)) in ( + (b.px + d, b.py - h / 2), (b.px - d - w, b.py - h / 2), + (b.px - w / 2, b.py + d), (b.px - w / 2, b.py - d - h), + (b.px + e, b.py + e), (b.px - e - w, b.py + e), + (b.px + e, b.py - e - h), (b.px - e - w, b.py - e - h), + ).enumerate() { + let box = (x: x, y: y, w: w, h: h) + let cost = k * 50 + i + if x < bounds.left or y < bounds.top or x + w > bounds.right or y + h > bounds.bottom { cost += 3000 } + cost += 10000 * taken.filter(o => _hit(o, box)).len() + for o in all { + if o.i == b.i { continue } + let (nx, ny) = (calc.min(calc.max(o.px, x), x + w), calc.min(calc.max(o.py, y), y + h)) + if ((nx - o.px) / 1pt) * ((nx - o.px) / 1pt) + ((ny - o.py) / 1pt) * ((ny - o.py) / 1pt) < (o.radius / 1pt) * (o.radius / 1pt) { + cost += 1000 + } + } + spots.push((cost: cost, box: box)) + } + } + spots.sorted(key: s => s.cost).first().box +} + +// Radius of the bubble for `size`: linear in size between the smallest and largest. +#let _bubble-radius(size, size-min, size-range, r) = r.lo + ((size - size-min) / size-range) * (r.hi - r.lo) + +// Size legend below a bubble chart: three reference circles. +#let _bubble-size-legend(size-label, size-max, max-radius, t) = if size-label != none { + let ref-sizes = ( + (size-max, str(calc.round(size-max))), + (calc.round(size-max * 0.5), str(calc.round(size-max * 0.5))), + (calc.round(size-max * 0.15), str(calc.round(size-max * 0.15))), + ) + draw-size-legend(ref-sizes, max-radius, size-max, t, title: size-label) +} + +/// Renders a scatter plot of x-y data points. +/// +/// - data (array, dictionary): Array of `(x, y)` tuples or dict with `x` and `y` arrays +/// - width (length): Chart width +/// - height (length): Chart height +/// - title (none, content): Optional chart title +/// - x-label (none, content): X-axis title +/// - y-label (none, content): Y-axis title +/// - point-size (length): Diameter of point markers +/// - show-grid (auto, bool): Draw background grid lines; `auto` uses theme default +/// - color (none, color): Override point color +/// - annotations (none, array): Optional annotation descriptors +/// - show-ticks (bool): Draw tick marks on the axis lines +/// - show-minor-grid (bool): Draw lighter minor grid lines between the major ones +/// - subtitle (none, content): Optional subtitle below the title +/// - radius (length): Corner radius of the chart container +/// - theme (none, dictionary): Theme overrides +/// -> content +#let scatter-plot( + data, + width: auto, + height: auto, + title: none, + x-label: none, + y-label: none, + point-size: 5pt, + show-grid: auto, + color: none, + annotations: none, + show-ticks: false, + show-minor-grid: false, + subtitle: none, + radius: 0pt, + theme: none, +) = context { + layout(size => { + validate-scatter-data(data, "scatter-plot") + let t = _scatter-theme(theme, show-grid) + let (width, height) = resolve-size(width, height, size, n: 10, theme: t) + let points = if type(data) == dictionary { data.x.zip(data.y) } else { data } + let point-color = if color != none { color } else { get-color(t, 0) } + let axes = _xy-axes(points, point-size / 2 + 1pt, x-label, y-label) + + cartesian-frame(width, height, t, x: axes.x, y: axes.y, + title: title, subtitle: subtitle, radius: radius, annotations: annotations, + show-ticks: show-ticks, show-minor-grid: show-minor-grid, + p => { + for pt in points { + place(left + top, dx: (p.px)(pt.at(0)) - point-size / 2, dy: (p.py)(pt.at(1)) - point-size / 2, + circle(radius: point-size / 2, fill: point-color, stroke: t.marker-stroke)) + } + }, + ) + }) +} + +/// Renders a multi-series scatter plot with color-coded point groups. +/// +/// - data (dictionary): Dict with `series` array, each containing `name` and `points` (array of `(x, y)`) +/// - width (length): Chart width +/// - height (length): Chart height +/// - title (none, content): Optional chart title +/// - x-label (none, content): X-axis title +/// - y-label (none, content): Y-axis title +/// - point-size (length): Diameter of point markers +/// - show-grid (auto, bool): Draw background grid lines; `auto` uses theme default +/// - show-legend (bool): Show series legend +/// - annotations (none, array): Optional annotation descriptors +/// - theme (none, dictionary): Theme overrides +/// - extra-legend-separation (length): Extra space between legend and chart +/// -> content +#let multi-scatter-plot( + data, + width: auto, + height: auto, + title: none, + x-label: none, + y-label: none, + point-size: 5pt, + show-grid: auto, + show-legend: true, + annotations: none, + extra-legend-separation: 0pt, + theme: none, +) = context { + layout(size => { + validate-multi-scatter-data(data, "multi-scatter-plot") + let t = _scatter-theme(theme, show-grid) + let (width, height) = resolve-size(width, height, size, n: 10, theme: t) + let series = data.series + let axes = _xy-axes(series.map(s => s.points).join(), point-size / 2 + 1pt, x-label, y-label) + + cartesian-frame(width, height, t, x: axes.x, y: axes.y, + title: title, annotations: annotations, extra-legend-separation: extra-legend-separation, + legend: draw-legend-auto(series.map(s => s.name), t, show-legend: show-legend, swatch-type: "circle", fit-width: true, max-width: legend-max-width(width, t)), + p => { + for (si, s) in series.enumerate() { + for pt in s.points { + place(left + top, dx: (p.px)(pt.at(0)) - point-size / 2, dy: (p.py)(pt.at(1)) - point-size / 2, + circle(radius: point-size / 2, fill: get-color(t, si), stroke: t.marker-stroke)) + } + } + }, + ) + }) +} + +/// Renders a bubble chart where each point has an x, y, and size dimension. +/// +/// - data (array, dictionary): Array of `(x, y, size)` tuples or dict with `x`, `y`, `size` arrays +/// - width (length): Chart width +/// - height (length): Chart height +/// - title (none, content): Optional chart title +/// - x-label (none, content): X-axis title +/// - y-label (none, content): Y-axis title +/// - min-radius (length): Minimum bubble radius +/// - max-radius (length): Maximum bubble radius +/// - show-grid (auto, bool): Draw background grid lines; `auto` uses theme default +/// - color (none, color): Override bubble color +/// - show-labels (bool): Display text labels on bubbles +/// - labels (none, array): Array of label strings for each bubble +/// - size-label (none, content): Title for the bubble size legend; `none` hides the legend +/// - annotations (none, array): Optional annotation descriptors +/// - theme (none, dictionary): Theme overrides +/// -> content +#let bubble-chart( + data, + width: auto, + height: auto, + title: none, + x-label: none, + y-label: none, + size-label: none, + min-radius: 5pt, + max-radius: 30pt, + show-grid: auto, + color: none, + show-labels: false, + labels: none, + annotations: none, + theme: none, +) = context { + layout(size => { + validate-bubble-data(data, "bubble-chart") + let t = _scatter-theme(theme, show-grid) + let (width, height) = resolve-size(width, height, size, n: 10, theme: t) + let points = if type(data) == dictionary { + data.x.zip(data.y).zip(data.size).map(p => (p.at(0).at(0), p.at(0).at(1), p.at(1))) + } else { data } + let sizes = points.map(p => p.at(2)) + let (size-min, size-max) = (calc.min(..sizes), calc.max(..sizes)) + let size-range = nonzero(size-max - size-min) + let bubble-color = if color != none { color } else { get-color(t, 0) } + let r = _bubble-radii(max-radius, min-radius, width, height, t) + let axes = _xy-axes(points, 0pt, x-label, y-label, + radii: sizes.map(s => _bubble-radius(s, size-min, size-range, r) + 1pt)) + + cartesian-frame(width, height, t, x: axes.x, y: axes.y, + title: title, annotations: annotations, + legend: _bubble-size-legend(size-label, size-max, max-radius, t), + p => { + let (px, py) = (p.px, p.py) + let bounds = (left: p.x0, right: p.x1, top: p.y0, bottom: p.y1) + let computed = points.enumerate().map(((i, pt)) => ( + i: i, px: px(pt.at(0)), py: py(pt.at(1)), + radius: _bubble-radius(pt.at(2), size-min, size-range, r), + )) + + for b in computed { + place(left + top, dx: b.px - b.radius, dy: b.py - b.radius, + circle(radius: b.radius, fill: bubble-color.transparentize(40%), stroke: bubble-color + 1.5pt)) + } + + // Labels: inside the bubble when they fit, otherwise beside it (with a leader + // line when it had to move away). Beside labels pick the nearest spot that + // clears every other label and every other bubble. + if show-labels and labels != none { + let lbl-size = t.axis-label-size + let lbl-h = lbl-size * 1.4 + let (inside, outside) = ((), ()) + let taken = () // boxes of the labels placed so far + let order = computed.sorted(key: b => -b.radius) // biggest bubbles first + for b in order { + if b.i >= labels.len() { continue } + let lbl = labels.at(b.i) + let lbl-w = measure(text(size: lbl-size, weight: "bold")[#lbl]).width + // (not below 80% of the axis label size: smaller text goes beside the bubble instead) + let fit = try-fit-label(b.radius * 2, b.radius * 2, lbl-size, label-len(lbl), shrink-min: lbl-size * 0.8) + let in-w = measure(text(size: fit.size, weight: "bold")[#lbl]).width + let box-in = (x: b.px - in-w / 2 - 1pt, y: b.py - fit.size * 0.7, w: in-w + 2pt, h: fit.size * 1.4) + // Fits its bubble and does not run into the label of a neighbouring bubble + // (the text box has to sit inside the outline, not on it) + let half = (in-w / 2 / 1pt, fit.size * 0.5 / 1pt) + let inside-ok = fit.fits and calc.sqrt(half.at(0) * half.at(0) + half.at(1) * half.at(1)) * 1pt <= b.radius - 0.75pt + if inside-ok and not taken.any(o => _hit(o, box-in)) { + inside.push((px: b.px, py: b.py, lbl: lbl, size: fit.size, w: in-w)) + taken.push(box-in) + } else { + let box = _label-spot(b, order, taken, bounds, lbl-w + 2pt, lbl-h) + taken.push(box) + outside.push((cx: b.px, cy: b.py, radius: b.radius, box: box, lbl: lbl)) + } + } + + for il in inside { + place(left + top, dx: il.px - il.w / 2 - 1pt, dy: il.py - il.size * 0.7, + box(width: il.w + 2pt, height: il.size * 1.4, + align(center + horizon, text(size: il.size, fill: t.text-color, weight: "bold")[#il.lbl]))) + } + for ol in outside { + let bx = ol.box + // Leader from the bubble edge to the label, when the label stands off the bubble + let near = (calc.min(calc.max(ol.cx, bx.x), bx.x + bx.w), calc.min(calc.max(ol.cy, bx.y), bx.y + bx.h)) + let (dx, dy) = ((near.at(0) - ol.cx) / 1pt, (near.at(1) - ol.cy) / 1pt) + let dist = calc.max(1.0, calc.sqrt(dx * dx + dy * dy)) + if dist * 1pt - ol.radius > 3.5pt { + place(left + top, line( + start: (ol.cx + dx / dist * ol.radius, ol.cy + dy / dist * ol.radius), end: near, + stroke: 0.5pt + t.text-color-light)) + } + place(left + top, dx: bx.x, dy: bx.y, + box(width: bx.w, height: bx.h, align(center + horizon, text(size: lbl-size, fill: t.text-color, weight: "bold")[#ol.lbl]))) + } + } + }, + ) + }) +} + +/// Renders a multi-series bubble chart with color-coded point groups and +/// per-point size dimension. +/// +/// - data (dictionary): Dict with `series` array, each containing `name` and +/// `points` (array of `(x, y, size)` tuples) +/// - width (length): Chart width +/// - height (length): Chart height +/// - title (none, content): Optional chart title +/// - x-label (none, content): X-axis title +/// - y-label (none, content): Y-axis title +/// - min-radius (length): Minimum bubble radius +/// - max-radius (length): Maximum bubble radius +/// - show-grid (auto, bool): Draw background grid lines; `auto` uses theme default +/// - show-legend (bool): Show series legend +/// - size-label (none, content): Title for the bubble size legend; `none` hides the legend +/// - annotations (none, array): Optional annotation descriptors +/// - theme (none, dictionary): Theme overrides +/// -> content +#let multi-bubble-chart( + data, + width: auto, + height: auto, + title: none, + x-label: none, + y-label: none, + size-label: none, + min-radius: 4pt, + max-radius: 25pt, + show-grid: auto, + show-legend: true, + annotations: none, + theme: none, +) = context { + layout(size => { + validate-multi-bubble-data(data, "multi-bubble-chart") + let t = _scatter-theme(theme, show-grid) + let (width, height) = resolve-size(width, height, size, n: 10, theme: t) + let series = data.series + let points = series.map(s => s.points).join() + let sizes = points.map(p => p.at(2)) + let (size-min, size-max) = (calc.min(..sizes), calc.max(..sizes)) + let size-range = nonzero(size-max - size-min) + let r = _bubble-radii(max-radius, min-radius, width, height, t) + let axes = _xy-axes(points, 0pt, x-label, y-label, + radii: sizes.map(s => _bubble-radius(s, size-min, size-range, r) + 1pt)) + let series-legend = draw-legend-auto(series.map(s => s.name), t, show-legend: show-legend, swatch-type: "circle", fit-width: true, max-width: legend-max-width(width, t)) + + cartesian-frame(width, height, t, x: axes.x, y: axes.y, + title: title, annotations: annotations, + legend: { series-legend; _bubble-size-legend(size-label, size-max, max-radius, t) }, + p => { + for (si, s) in series.enumerate() { + let color = get-color(t, si) + for pt in s.points { + let rad = _bubble-radius(pt.at(2), size-min, size-range, r) + place(left + top, dx: (p.px)(pt.at(0)) - rad, dy: (p.py)(pt.at(1)) - rad, + circle(radius: rad, fill: color.transparentize(40%), stroke: color + 1.5pt)) + } + } + }, + ) + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/slope.typ b/packages/preview/primaviz/0.12.0/src/charts/slope.typ new file mode 100644 index 0000000000..3d754a875d --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/slope.typ @@ -0,0 +1,148 @@ +// slope.typ - Slope chart (two-period comparison) +#import "../theme.typ": _resolve-ctx, get-color +#import "../util.typ": nonzero +#import "../validate.typ": validate-slope-data +#import "../primitives/container.typ": chart-container +#import "../primitives/layout.typ": resolve-size, pin, text-width, text-height, fit-font-size, spread-labels +#import "../primitives/rows.typ": value-str + +/// Renders a slope chart showing changes between two time periods. +/// +/// Each item is drawn as a line connecting its start-value (left axis) +/// to its end-value (right axis), with dots and labels at both endpoints. +/// Labels of values that are close are spread apart (with a short leader to +/// their dot) so that none overlaps another. +/// +/// - data (dictionary): Must contain `labels`, `start-values`, `end-values`, +/// and optionally `start-label` and `end-label`. +/// - width (length): Chart width +/// - height (length): Chart height +/// - title (none, content): Optional chart title +/// - dot-size (auto, length): Diameter of endpoint dots; `auto` scales with the theme's `base-size` (0.75x, 6pt in the default theme) +/// - line-width (auto, length): Stroke width of connecting lines; `auto` uses the theme's `stroke-thick` +/// - show-values (bool): Display numeric values next to dots +/// - theme (none, dictionary): Theme overrides +/// -> content +#let slope-chart( + data, + width: auto, + height: auto, + title: none, + dot-size: auto, + line-width: auto, + show-values: true, + theme: none, +) = context { + layout(size => { + validate-slope-data(data, "slope-chart") + let t = _resolve-ctx(theme) + let dot-size = if dot-size == auto { 0.75 * t.base-size } else { dot-size } + let line-width = if line-width == auto { t.stroke-thick } else { line-width } + let labels = data.labels + let n = labels.len() + let (width, height) = resolve-size(width, height, size, n: n, theme: t, rows: true, row-pitch: t.cell-size * 1.5) + + let start-values = data.start-values + let end-values = data.end-values + let start-label = if "start-label" in data { data.start-label } else { "Start" } + let end-label = if "end-label" in data { data.end-label } else { "End" } + + let all-values = start-values + end-values + let max-val = calc.max(..all-values) + let min-val = calc.min(..all-values) + let val-range = max-val - min-val + + // Endpoint texts: "name value" on the left, "value name" on the right + let left-text(i) = if show-values { [#labels.at(i) #value-str(start-values.at(i), t)] } else { [#labels.at(i)] } + let right-text(i) = if show-values { [#value-str(end-values.at(i), t) #labels.at(i)] } else { [#labels.at(i)] } + let dot-r = dot-size / 2 + let stem = dot-r + 6pt // dot centre to label edge; the leader crosses it + + // Column headers over each axis + let head-size = t.axis-title-size + let head-h = text-height(start-label, head-size, weight: "bold") + let pad-top = head-h + t.axis-label-gap * 1.5 + + // Label size: shrink so each side's longest label takes at most 30% of the chart, + // and so that all the rows fit the height (a label is 1.05 em at its tightest) + let side-cap = width * 0.34 - stem + let widest(side) = array.range(n).fold(none, (best, i) => { + let cur = side(i) + if best == none or text-width(cur, t.axis-label-size) > text-width(best, t.axis-label-size) { cur } else { best } + }) + let w-left = widest(left-text) + let w-right = widest(right-text) + let lbl-size = calc.min( + fit-font-size(w-left, side-cap, t.axis-label-size, min: 6.5pt), + fit-font-size(w-right, side-cap, t.axis-label-size, min: 6.5pt), + calc.max(5.5pt, (height - pad-top) / (1.06 * n + 0.3)), + ) + let left-w = calc.min(text-width(w-left, lbl-size), side-cap) + let right-w = calc.min(text-width(w-right, lbl-size), side-cap) + let axis-x-left = left-w + stem + let axis-x-right = width - right-w - stem + + // One label's height, with leading: the tallest of all, so that labels which + // wrap in a narrow gutter still clear each other + let tall = calc.max(0pt, ..array.range(n).map(i => calc.max( + measure(box(width: left-w + 1pt, text(size: lbl-size)[#left-text(i)])).height, + measure(box(width: right-w + 1pt, text(size: lbl-size)[#right-text(i)])).height, + ))) + let lh = calc.max(lbl-size * 1.3, tall * 1.15) + // Crowded labels may close up to their ink and a hair (a line of text is about 1.05 em) + // before any is left out (more names than the height holds) + let lh-min = calc.max(lbl-size * 1.05, tall * 1.05) + let y-top = pad-top + lh / 2 + let y-bot = height - lh / 2 + let y-of(v) = if val-range == 0 { (y-top + y-bot) / 2 } else { + y-top + (1 - (v - min-val) / val-range) * (y-bot - y-top) + } + let ys-start = start-values.map(y-of) + let ys-end = end-values.map(y-of) + let ly-start = spread-labels(ys-start, sizes: lh, min-gap: lh-min - lh, lo: pad-top, hi: height) + let ly-end = spread-labels(ys-end, sizes: lh, min-gap: lh-min - lh, lo: pad-top, hi: height) + + let axis-stroke = t.stroke-thin + t.text-color-light.transparentize(50%) + + chart-container(width, height, title, t)[ + #box(width: width, height: height)[ + // Headers + #for (x, name) in ((axis-x-left, start-label), (axis-x-right, end-label)) { + let w = text-width(name, head-size, weight: "bold") + place(left + top, dx: calc.max(0pt, calc.min(width - w, x - w / 2)), dy: 0pt, + text(size: head-size, weight: "bold", fill: t.text-color)[#name]) + } + + // Light axes: the dots carry the data + #place(left + top, line(start: (axis-x-left, pad-top), end: (axis-x-left, height), stroke: axis-stroke)) + #place(left + top, line(start: (axis-x-right, pad-top), end: (axis-x-right, height), stroke: axis-stroke)) + + #for (i, lbl) in labels.enumerate() { + let color = get-color(t, i) + let (y0, y1) = (ys-start.at(i), ys-end.at(i)) + place(left + top, line(start: (axis-x-left, y0), end: (axis-x-right, y1), stroke: line-width + color)) + place(left + top, dx: axis-x-left - dot-r, dy: y0 - dot-r, circle(radius: dot-r, fill: color, stroke: t.marker-stroke)) + place(left + top, dx: axis-x-right - dot-r, dy: y1 - dot-r, circle(radius: dot-r, fill: color, stroke: t.marker-stroke)) + + // Left label, right-aligned against its axis + let (yl, yr) = (ly-start.at(i), ly-end.at(i)) + if yl != none and calc.abs(yl - y0) > 0.5pt { + place(left + top, line(start: (axis-x-left - dot-r, y0), end: (axis-x-left - stem + 1pt, yl), stroke: 0.5pt + color)) + } + if yl != none { + place(left + top, dx: axis-x-left - stem - left-w - 1pt, dy: yl, + box(width: left-w + 1pt, height: 0pt, align(right + horizon, text(size: lbl-size, fill: t.text-color)[#left-text(i)]))) + } + // Right label + if yr != none and calc.abs(yr - y1) > 0.5pt { + place(left + top, line(start: (axis-x-right + dot-r, y1), end: (axis-x-right + stem - 1pt, yr), stroke: 0.5pt + color)) + } + if yr != none { + place(left + top, dx: axis-x-right + stem, dy: yr, + box(width: right-w + 1pt, height: 0pt, align(left + horizon, text(size: lbl-size, fill: t.text-color)[#right-text(i)]))) + } + } + ] + ] + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/sparkline.typ b/packages/preview/primaviz/0.12.0/src/charts/sparkline.typ new file mode 100644 index 0000000000..53834fac27 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/sparkline.typ @@ -0,0 +1,186 @@ +// sparkline.typ - Tiny inline charts for tables and running text +#import "../theme.typ": _resolve-ctx, get-color +#import "../primitives/layout.typ": resolve-size +#import "../primitives/paths.typ": draw-polyline + +/// Renders a tiny inline line chart (sparkline) suitable for tables and running text. +/// +/// - values (array): Array of numeric values +/// - width (length): Sparkline width +/// - height (length): Sparkline height +/// - color (none, color): Override line color +/// - show-endpoint (bool): Draw a dot at the last data point +/// - fill-area (bool): Fill the area beneath the line +/// - stroke-width (length): Line stroke width +/// - theme (none, dictionary): Theme overrides +/// -> content +#let sparkline( + values, + width: 60pt, + height: 15pt, + color: none, + show-endpoint: true, + fill-area: false, + stroke-width: 1pt, + theme: none, +) = context { + layout(size => { + let t = _resolve-ctx(theme) + let (width, height) = resolve-size(width, height, size, container: false) + let c = if color != none { color } else { get-color(t, 0) } + let n = values.len() + + if n == 0 { + box(width: width, height: height, baseline: -3pt) + } else if n == 1 { + // Single value: just a dot in the center + box(width: width, height: height, baseline: -3pt)[ + #place(left + top, dx: width / 2 - 2pt, dy: height / 2 - 2pt, + circle(radius: 2pt, fill: c, stroke: none)) + ] + } else { + let min-val = calc.min(..values) + let max-val = calc.max(..values) + // A flat series is a line through the middle, not along the bottom edge + let flat = max-val == min-val + let val-range = if flat { 1 } else { max-val - min-val } + let dot-r = calc.max(1.2pt, 2 * stroke-width) + // Keep the line and its endpoint dot inside the box, so neither touches + // a neighbouring table border or the line of text above + let pad = if show-endpoint { dot-r } else { stroke-width / 2 } + let (inner-w, inner-h) = (width - 2 * pad, height - 2 * pad) + + // Compute points: x evenly spaced, y scaled + let points = () + for i in array.range(n) { + let x = pad + (i / (n - 1)) * inner-w + let y = pad + if flat { inner-h / 2 } else { inner-h - ((values.at(i) - min-val) / val-range) * inner-h } + points.push((x, y)) + } + + box(width: width, height: height, baseline: -3pt)[ + // Fill area beneath the line + #if fill-area { + let fill-points = () + // Start at bottom-left + fill-points.push((pad, height)) + for pt in points { + fill-points.push((pt.at(0), pt.at(1))) + } + // End at bottom-right + fill-points.push((width - pad, height)) + place(left + top, + polygon(fill: c.transparentize(70%), stroke: none, ..fill-points)) + } + // Draw line segments + #draw-polyline(points, stroke-width + c) + // Endpoint dot + #if show-endpoint { + let last = points.at(n - 1) + place(left + top, dx: last.at(0) - dot-r, dy: last.at(1) - dot-r, + circle(radius: dot-r, fill: c, stroke: none)) + } + ] + } + }) +} + +/// Renders a tiny inline bar chart suitable for tables and running text. +/// +/// - values (array): Array of numeric values +/// - width (length): Sparkbar width +/// - height (length): Sparkbar height +/// - color (none, color): Override bar color +/// - gap (length): Gap between bars +/// - theme (none, dictionary): Theme overrides +/// -> content +#let sparkbar( + values, + width: 60pt, + height: 15pt, + color: none, + gap: 1pt, + theme: none, +) = context { + layout(size => { + let t = _resolve-ctx(theme) + let (width, height) = resolve-size(width, height, size, container: false) + let c = if color != none { color } else { get-color(t, 0) } + let n = values.len() + + if n == 0 { + box(width: width, height: height, baseline: -3pt) + } else { + // Bars grow from zero, so the scale always spans it (negatives hang down) + let hi = calc.max(0, ..values) + let lo = calc.min(0, ..values) + let span = if hi == lo { 1 } else { hi - lo } + let zero-y = hi / span * height + // Many bars: the gaps give way before the bars vanish + let gap = calc.min(gap, width / n * 0.3) + let bar-width = (width - gap * (n - 1)) / n + + box(width: width, height: height, baseline: -3pt)[ + #for i in array.range(n) { + let v = values.at(i) + // a zero is still a mark: a hairline on the baseline + let bar-height = calc.max(calc.abs(v) / span * height, 0.5pt) + let x = i * (bar-width + gap) + let y = if v >= 0 { zero-y - bar-height } else { zero-y } + place(left + top, dx: x, dy: y, + rect(width: bar-width, height: bar-height, fill: c, stroke: none)) + } + ] + } + }) +} + +/// Renders a tiny inline dot chart suitable for tables and running text. +/// +/// - values (array): Array of numeric values +/// - width (length): Chart width +/// - height (length): Chart height +/// - color (none, color): Override dot color +/// - dot-size (length): Diameter of each dot +/// - theme (none, dictionary): Theme overrides +/// -> content +#let sparkdot( + values, + width: 60pt, + height: 15pt, + color: none, + dot-size: 5pt, + theme: none, +) = context { + layout(size => { + let t = _resolve-ctx(theme) + let (width, height) = resolve-size(width, height, size, container: false) + let c = if color != none { color } else { get-color(t, 0) } + let n = values.len() + + if n == 0 { + box(width: width, height: height, baseline: -3pt) + } else { + let min-val = calc.min(..values) + let max-val = calc.max(..values) + let flat = max-val == min-val + let val-range = if flat { 1 } else { max-val - min-val } + // Many points: the dots shrink until they stop overlapping + let dot-size = if n > 1 { calc.min(dot-size, calc.max(1.5pt, (width - dot-size) / (n - 1) * 1.1)) } else { dot-size } + + box(width: width, height: height, baseline: -3pt)[ + // Pad drawing area by max dot radius to prevent clipping + #let max-r = dot-size / 2 * 1.3 + #for i in array.range(n) { + let x = if n == 1 { width / 2 } else { max-r + (i / (n - 1)) * (width - 2 * max-r) } + let y = if flat { height / 2 } else { max-r + (height - 2 * max-r) - ((values.at(i) - min-val) / val-range) * (height - 2 * max-r) } + let is-last = i == n - 1 + let r = if is-last { dot-size / 2 * 1.3 } else { dot-size / 2 } + let dot-color = if is-last { c.darken(20%) } else { c } + place(left + top, dx: x - r, dy: y - r, + circle(radius: r, fill: dot-color, stroke: none)) + } + ] + } + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/sunburst.typ b/packages/preview/primaviz/0.12.0/src/charts/sunburst.typ new file mode 100644 index 0000000000..253dc6e859 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/sunburst.typ @@ -0,0 +1,248 @@ +// sunburst.typ - Multi-level pie/donut chart for hierarchical data +#import "../theme.typ": _resolve-ctx, get-color +#import "../validate.typ": validate-sunburst-data +#import "../primitives/container.typ": chart-container +#import "../primitives/polar.typ": place-wedge, separator-stroke +#import "../primitives/polar-labels.typ": fit-wedge-label, label-ink, place-centred, place-outside-labels, polar-label-extent +#import "../primitives/layout.typ": resolve-size + +/// Computes the depth of a hierarchical node tree. +/// +/// - node (dictionary): A node with optional `children` array +/// -> int +#let _get-node-depth(node) = { + if "children" not in node or node.children.len() == 0 { + return 1 + } + let deepest = 0 + for child in node.children { + let d = _get-node-depth(child) + if d > deepest { + deepest = d + } + } + return 1 + deepest +} + +/// A name of several words as two lines, broken where they come out most +/// alike in length; `none` for a name without a space (or not a string). +/// +/// - name (str, content): A segment's name +/// -> none, array of two strings +#let _two-lines(name) = { + if type(name) != str or " " not in name { return none } + let words = name.split(" ").filter(w => w != "") + if words.len() < 2 { return none } + let best = 1 + let best-gap = none + for k in range(1, words.len()) { + let gap = calc.abs(words.slice(0, k).join(" ").clusters().len() - words.slice(k).join(" ").clusters().len()) + if best-gap == none or gap < best-gap { + best = k + best-gap = gap + } + } + (words.slice(0, best).join(" "), words.slice(best).join(" ")) +} + +/// Builds a flat list of arc segments from the hierarchical data. +/// Each segment has: start-angle, end-angle, depth, color-index, name, value. +/// +/// - node (dictionary): Current node +/// - start-angle (float): Start angle in degrees +/// - end-angle (float): End angle in degrees +/// - depth (int): Current depth (0 = root, 1 = first visible ring) +/// - max-depth (none, int): Maximum depth to include in segments +/// - color-index (int): Index into the theme palette for this branch +/// -> array +#let _collect-segments(node, start-angle, end-angle, depth, max-depth, color-index) = { + let segments = () + + // Stop the recursion if maximum depth is reached + if max-depth != none and depth > max-depth { + return segments + } + + // Only emit a segment for depth >= 1 (skip the root) + if depth >= 1 { + segments.push(( + start-angle: start-angle, + end-angle: end-angle, + depth: depth, + color-index: color-index, + name: node.name, + value: node.value, + )) + } + + // Recurse into children + if "children" in node and node.children.len() > 0 { + let total-child-value = node.children.map(c => c.value).sum() + let child-end-angle = if ( + total-child-value > 0 and "value" in node and node.value > 0 + ) { + start-angle + calc.min(total-child-value / node.value, 1.0) * (end-angle - start-angle) + } else { end-angle } + let current = start-angle + for (i, child) in node.children.enumerate() { + let span = if total-child-value > 0 { + (child.value / total-child-value) * (child-end-angle - start-angle) + } else { 0 } + let child-color = if depth == 0 { i } else { color-index } + let child-segs = _collect-segments(child, current, current + span, depth + 1, max-depth, child-color) + segments = segments + child-segs + current = current + span + } + } + + segments +} + +/// Renders a sunburst chart from hierarchical data. +/// +/// Inner ring represents the root's children; outer rings represent deeper levels. +/// Each segment's angular span is proportional to its value relative to siblings. +/// +/// A name is written inside its segment, as large as fits and in a colour +/// readable on that fill; if only a narrow segment is left it is turned along +/// the radius or the arc. An outermost segment that still cannot hold its name +/// gets it outside, on a leader line. +/// +/// - data (dictionary): Hierarchical data with `name`, `value`, and `children` keys +/// - size (length): Diameter of the chart +/// - inner-radius (length): Radius of the empty center hole +/// - ring-width (length): Width of each concentric ring +/// - max-depth (none, int): Maximum number of rings to render +/// - total-angle (angle): Total angle swept by the rings +/// - start-angle (angle): Angle at which the first segment starts, measured +/// clockwise from 3 o'clock; the default `-90deg` starts at 12 o'clock +/// - min-angle (angle): Minimum angle for a segment to be rendered +/// - title (none, content): Optional chart title +/// - show-labels (bool): Display name labels on segments +/// - theme (none, dictionary): Theme overrides +/// -> content +#let sunburst-chart( + data, + size: 300pt, + inner-radius: 40pt, + ring-width: 35pt, + max-depth: none, + total-angle: 360deg, + start-angle: -90deg, + min-angle: 0.1deg, + title: none, + show-labels: true, + theme: none, +) = context { + layout(avail => { + validate-sunburst-data(data, "sunburst-chart") + let t = _resolve-ctx(theme) + let size = resolve-size(size, size, avail).width + + // Compute depth (excluding root) to determine how many rings we need + let total-depth = _get-node-depth(data) - 1 // rings = depth levels below root + let total-depth = if max-depth != none { + calc.min(total-depth, max-depth) + } else { total-depth } // cap at max-depth + + // Adjust size if rings would overflow + let rim = inner-radius + ring-width * total-depth + let chart-size = calc.max(size, 2 * rim + 10pt) + + // Collect arc segments + let segments = _collect-segments( + data, + start-angle.deg(), + total-angle.deg() + start-angle.deg(), + 0, + max-depth, + 0 + ) + + // Resolve geometry and color once per visible segment, so the wedges and the + // labels can be drawn in separate passes below. Segments too thin to see + // are dropped here rather than in each pass. + let visible = segments + .filter(seg => seg.end-angle - seg.start-angle >= min-angle.deg()) + .map(seg => { + let depth = seg.depth // 1-based (1 = innermost ring) + // Color: base color from palette, lighten for deeper levels + let base-color = get-color(t, seg.color-index) + ( + ..seg, + span: seg.end-angle - seg.start-angle, + mid: (seg.start-angle + seg.end-angle) / 2, + r-inner: inner-radius + (depth - 1) * ring-width, + r-outer: inner-radius + depth * ring-width, + color: if depth == 1 { + base-color + } else if depth == 2 { + base-color.lighten(25%) + } else if depth == 3 { + base-color.lighten(45%) + } else { + base-color.lighten(60%) + }, + ) + }) + + // Labels: inside when they fit, else (outermost ring only) outside + let plan = { + let inside = () + let outside = () + if show-labels { + for seg in visible { + let weight = if seg.depth == 1 { "bold" } else { "regular" } + let ink = label-ink(seg.color, t) + let make(sz) = text(size: sz, weight: weight, fill: ink, top-edge: "cap-height", bottom-edge: "descender")[#seg.name] + let fit = fit-wedge-label(make, seg.mid, seg.span / 2, seg.r-inner, seg.r-outer, t.value-label-size, t.axis-label-size, rotate: true) + // A sunburst has no legend to carry a name that does not fit, and + // type below the theme's smallest is not read. A name of several words + // that fits nowhere on one line is tried on two (still no smaller than + // that); a name that fits nowhere goes outside, with a leader from + // inside its segment unless it is on the outermost ring + let body-of = make + let lines = _two-lines(seg.name) + if fit == none and lines != none { + let make2(sz) = { + set par(leading: 0.3em) + text(size: sz, weight: weight, fill: ink, top-edge: "cap-height", bottom-edge: "descender")[#lines.at(0) #linebreak() #lines.at(1)] + } + fit = fit-wedge-label(make2, seg.mid, seg.span / 2, seg.r-inner, seg.r-outer, t.value-label-size, t.axis-label-size, rotate: true) + body-of = make2 + } + let outer = seg.r-outer >= rim - 0.01pt + if fit != none { + inside.push((seg: seg, fit: fit, body: body-of(fit.size))) + } else { + // Inner rings first when there is no room for everything + outside.push((angle: seg.mid, priority: seg.span + 20 * (total-depth - seg.depth), + from: if outer { none } else { (seg.r-inner + seg.r-outer) / 2 }, + body: text(size: t.axis-label-size, fill: t.text-color, top-edge: "cap-height", bottom-edge: "descender")[#seg.name])) + } + } + } + (inside: inside, outside: outside) + } + let ext = polar-label-extent(plan.outside, rim, gap: 4pt) + let (box-w, box-h) = (calc.max(chart-size, 2 * ext.x), calc.max(chart-size, 2 * ext.y)) + let (cx, cy) = (box-w / 2, box-h / 2) + + align(center, chart-container(box-w, box-h, title, t)[ + #box(width: box-w, height: box-h)[ + // Wedges first: placed content paints in order, so every label ends up + // above every wedge, not under the next ring out + #for seg in visible { + place-wedge(cx, cy, seg.r-inner, seg.r-outer, seg.start-angle, seg.end-angle, seg.color, + stroke: separator-stroke(t, thickness: 0.75pt)) + } + #for l in plan.inside { + let (x, y) = (cx + l.fit.r * calc.cos(l.seg.mid * 1deg), cy + l.fit.r * calc.sin(l.seg.mid * 1deg)) + place-centred(x, y, l.fit.w, l.fit.h, l.body, rot: l.fit.rot) + } + #place-outside-labels(plan.outside, cx, cy, rim, (left: 0pt, right: box-w, top: 0pt, bottom: box-h), + leader-stroke: 0.5pt + t.text-color-light) + ] + ]) + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/timeline.typ b/packages/preview/primaviz/0.12.0/src/charts/timeline.typ new file mode 100644 index 0000000000..f1b373864f --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/timeline.typ @@ -0,0 +1,112 @@ +// timeline.typ - Vertical event timeline chart +#import "../theme.typ": _resolve-ctx, get-color +#import "../validate.typ": validate-timeline-data +#import "../primitives/container.typ": chart-container +#import "../primitives/layout.typ": resolve-size + +/// Renders a vertical event timeline — a series of dated events along a +/// central vertical line, alternating left and right for visual variety. +/// +/// Ideal for changelogs, milestone trackers, project histories, and roadmaps. +/// +/// Events are spaced `event-gap` apart, and pushed further apart where the +/// measured text of two events on the same side would otherwise overlap. +/// +/// - data (dictionary): Must contain `events` array. Each event is a dict with +/// `date` (str), `title` (str), and optional `description` (str) and +/// `category` (str) for color coding. +/// - width (length): Chart width +/// - event-gap (auto, length): Vertical spacing between events; `auto` scales with the theme's `base-size` (7.5x, 60pt in the default theme) +/// - title (none, content): Optional chart title +/// - marker-size (auto, length): Diameter of event marker circles; `auto` scales with the theme's `base-size` (1.5x, 12pt in the default theme) +/// - theme (none, dictionary): Theme overrides +/// -> content +#let timeline-chart( + data, + width: auto, + event-gap: auto, + title: none, + marker-size: auto, + theme: none, +) = context { + layout(size => { + validate-timeline-data(data, "timeline-chart") + let t = _resolve-ctx(theme) + let event-gap = if event-gap == auto { 7.5 * t.base-size } else { event-gap } + let marker-size = if marker-size == auto { 1.5 * t.base-size } else { marker-size } + let width = resolve-size(width, 0pt, size, n: 10, theme: t).width + let events = data.events + + // Center x position for the vertical spine + let center-x = width / 2 + + // Horizontal arm from the spine, then a gap before the text + let arm-length = 20pt + let text-gap = 8pt + let margin = 10pt + let text-area-width = calc.max(20pt, center-x - arm-length - text-gap - margin) + + // Category-to-color mapping for optional color coding + let category-names = () + for ev in events { + if "category" in ev and ev.category not in category-names { category-names.push(ev.category) } + } + + // Date (small, muted), title (bold) and optional description, stacked as + // one block; measured, so the spacing below can respect its real height + let blocks = events.enumerate().map(((i, ev)) => { + let is-left = calc.rem(i, 2) == 0 + let text-align = if is-left { right } else { left } + let lines = ( + text(size: t.axis-label-size, fill: t.text-color-light, weight: "medium")[#ev.date], + text(size: t.value-label-size, fill: t.text-color, weight: "bold")[#ev.title], + ) + if "description" in ev { + lines.push(text(size: t.axis-label-size, fill: t.text-color-light)[#ev.description]) + } + let body = box(width: text-area-width, + stack(dir: ttb, spacing: t.axis-label-size * 0.45, ..lines.map(l => align(text-align, l)))) + (body: body, h: measure(body).height) + }) + + // Event positions: `event-gap` apart, but never closer than the text of the + // previous event on the same side (two back) or the previous marker allows + let text-gap-v = t.axis-label-gap + let ys = () + for (i, blk) in blocks.enumerate() { + let y = calc.max(blk.h / 2 + 4pt, 20pt + i * event-gap) + if i >= 1 { y = calc.max(y, ys.at(i - 1) + marker-size + 2pt) } + if i >= 2 { y = calc.max(y, ys.at(i - 2) + (blocks.at(i - 2).h + blk.h) / 2 + text-gap-v) } + ys.push(y) + } + let chart-height = ys.last() + calc.max(blocks.last().h / 2, marker-size / 2) + 20pt + + chart-container(width, chart-height, title, t)[ + #box(width: width, height: chart-height)[ + // Central vertical line (spine) + #place(left + top, dx: center-x, dy: 10pt, + line(start: (0pt, 0pt), end: (0pt, chart-height - 20pt), stroke: t.stroke-mid + t.text-color-light)) + + #for (i, ev) in events.enumerate() { + let y-pos = ys.at(i) + let is-left = calc.rem(i, 2) == 0 + let color-idx = if category-names.len() > 0 and "category" in ev { + let idx = category-names.position(c => c == ev.category) + if idx == none { i } else { idx } + } else { i } + + // Arm from the spine to the text + place(left + top, dx: if is-left { center-x - arm-length } else { center-x }, dy: y-pos, + line(start: (0pt, 0pt), end: (arm-length, 0pt), stroke: t.stroke-thin + t.text-color-light)) + + // Marker on the spine + place(left + top, dx: center-x - marker-size / 2, dy: y-pos - marker-size / 2, + circle(radius: marker-size / 2, fill: get-color(t, color-idx), stroke: t.marker-stroke)) + + let text-x = if is-left { margin } else { center-x + arm-length + text-gap } + place(left + top, dx: text-x, dy: y-pos - blocks.at(i).h / 2, blocks.at(i).body) + } + ] + ] + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/treemap.typ b/packages/preview/primaviz/0.12.0/src/charts/treemap.typ new file mode 100644 index 0000000000..2f2517dae6 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/treemap.typ @@ -0,0 +1,214 @@ +// treemap.typ - Treemap chart (squarified layout) +#import "../theme.typ": _resolve-ctx, get-color +#import "../util.typ": normalize-data, nonzero, contrast-text +#import "../validate.typ": validate-simple-data +#import "../primitives/container.typ": chart-container +#import "../primitives/layout.typ": fit-cell-label, resolve-size +#import "../primitives/rows.typ": value-str + +/// Renders a treemap chart displaying hierarchical data as nested rectangles +/// sized proportionally to their values. +/// +/// Uses a squarified layout algorithm for visually balanced aspect ratios. +/// +/// - data (dictionary, array): Label-value pairs as dict or array of tuples +/// - width (length): Chart width +/// - height (length): Chart height +/// - title (none, content): Optional chart title +/// - show-values (bool): Display value labels inside rectangles +/// - gap (length): Gap between rectangles +/// - theme (none, dictionary): Theme overrides +/// -> content +#let treemap( + data, + width: auto, + height: auto, + title: none, + show-values: true, + gap: 1.5pt, + theme: none, +) = context { + layout(size => { + validate-simple-data(data, "treemap") + let t = _resolve-ctx(theme) + let (width, height) = resolve-size(width, height, size, n: 8, theme: t) + let norm = normalize-data(data) + let labels = norm.labels + let values = norm.values + + let n = values.len() + if n == 0 { return } + + // Build index-sorted pairs by value descending + let indexed = array.range(n).map(i => (idx: i, val: values.at(i))) + let sorted = indexed.sorted(key: item => -item.val) + + let total = nonzero(values.sum()) + + // Squarified treemap layout algorithm + // Returns array of (idx, x, y, w, h) rectangles + let layout-rects = { + // We'll use a strip-based squarified approach + // For each strip we greedily add items while the worst aspect ratio improves + let rects = () + let remaining = sorted + let rx = 0.0 // remaining area x + let ry = 0.0 // remaining area y + let rw = width / 1pt // remaining width (unitless) + let rh = height / 1pt // remaining height (unitless) + let remaining-total = total + + // Worst aspect ratio of a strip: given items with values, laid out along + // a strip of length `side`, with total area `strip-area` for those items + let worst-ratio(strip-values, side, strip-area) = { + if strip-values.len() == 0 or side == 0 or strip-area == 0 { return 999.0 } + let worst = 0.0 + for v in strip-values { + let item-area = strip-area * (v / strip-values.sum()) + if item-area == 0 { continue } + let other-side = item-area / side + let ratio = if side > other-side { side / other-side } else { other-side / side } + if ratio > worst { worst = ratio } + } + worst + } + + while remaining.len() > 0 { + if rw <= 0 or rh <= 0 { break } + + let total-area = rw * rh + // Fraction of total value that maps to remaining area + let area-scale = total-area / calc.max(remaining-total, 0.001) + + // Determine the short side (we lay strips along the short side) + let short-side = calc.min(rw, rh) + let horizontal = rw >= rh // strips go left-to-right if wider + + let strip = () + let strip-vals = () + let strip-value-sum = 0 + + // Greedily add items to strip + for (si, item) in remaining.enumerate() { + let new-vals = strip-vals + (item.val,) + let new-sum = strip-value-sum + item.val + let strip-area = new-sum * area-scale + + let new-ratio = worst-ratio(new-vals, short-side, strip-area) + + if strip-vals.len() > 0 { + let old-area = strip-value-sum * area-scale + let old-ratio = worst-ratio(strip-vals, short-side, old-area) + if new-ratio > old-ratio { + // Adding this item makes it worse, stop here + break + } + } + + strip.push(item) + strip-vals = new-vals + strip-value-sum = new-sum + } + + // If no items added (shouldn't happen), force one + if strip.len() == 0 { + strip = (remaining.at(0),) + strip-vals = (remaining.at(0).val,) + strip-value-sum = remaining.at(0).val + } + + // Lay out the strip + let strip-area = strip-value-sum * area-scale + let strip-thickness = if short-side > 0 { strip-area / short-side } else { 0 } + + let offset = 0.0 + for item in strip { + let item-frac = if strip-value-sum > 0 { item.val / strip-value-sum } else { 1.0 / strip.len() } + let item-length = short-side * item-frac + + if horizontal { + // Strip goes left-to-right, items stacked top-to-bottom + rects.push(( + idx: item.idx, + x: rx, + y: ry + offset, + w: strip-thickness, + h: item-length, + )) + } else { + // Strip goes top-to-bottom, items laid left-to-right + rects.push(( + idx: item.idx, + x: rx + offset, + y: ry, + w: item-length, + h: strip-thickness, + )) + } + + offset += item-length + } + + // Shrink remaining area + if horizontal { + rx += strip-thickness + rw -= strip-thickness + } else { + ry += strip-thickness + rh -= strip-thickness + } + + remaining-total -= strip-value-sum + remaining = remaining.slice(strip.len()) + } + + rects + } + + // Render + let gap-val = gap / 1pt + + chart-container(width, height, title, t)[ + #box(width: width, height: height)[ + #for r in layout-rects { + let i = r.idx + let rx = r.x * 1pt + gap / 2 + let ry = r.y * 1pt + gap / 2 + let rw = calc.max(r.w * 1pt - gap, 0pt) + let rh = calc.max(r.h * 1pt - gap, 0pt) + + // Rectangle fill + place( + left + top, + dx: rx, + dy: ry, + rect( + width: rw, + height: rh, + fill: get-color(t, i), + stroke: none, + radius: 2pt, + ) + ) + + // Label and value, measured: the largest size that fits the cell + // (wrapping at spaces if needed), the value only if it fits under it + { + let txt-color = contrast-text(get-color(t, i), theme: t) + let value-text = if show-values { value-str(values.at(i), t) } else { none } + let (pad-x, pad-y) = (4pt, 3pt) + let fit = fit-cell-label(labels.at(i), value-text, rw - 2 * pad-x, rh - 2 * pad-y, t.value-label-size, min: 5.5pt) + if fit != none { + let name-t = text(size: fit.size, fill: txt-color, weight: "bold")[#labels.at(i)] + let body = if fit.detail { + stack(dir: ttb, spacing: 0.25 * fit.size, name-t, + text(size: fit.size * 0.85, fill: txt-color.transparentize(20%))[#value-text]) + } else { name-t } + place(left + top, dx: rx + pad-x, dy: ry + pad-y, box(width: rw - 2 * pad-x, body)) + } + } + } + ] + ] + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/violin.typ b/packages/preview/primaviz/0.12.0/src/charts/violin.typ new file mode 100644 index 0000000000..682f64e7ed --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/violin.typ @@ -0,0 +1,133 @@ +// violin.typ - Violin plot (distribution shape visualization) +#import "../theme.typ": _resolve-ctx, get-color +#import "../util.typ": nonzero +#import "../validate.typ": validate-violin-data +#import "../primitives/frame.typ": cartesian-frame +#import "../primitives/axes.typ": value-axis, category-axis +#import "../primitives/layout.typ": resolve-size + +/// Renders a violin plot showing the full density shape of distributions. +/// +/// Each group's data is visualised as a mirrored kernel density estimate (KDE) +/// curve, optionally with a small box-plot (median dot + IQR bar) inside. The +/// value axis covers the curves, nothing more. +/// +/// - data (dictionary): Dict with `labels` (array of str) and `datasets` +/// (array of arrays of numbers). Each dataset is the raw observations for +/// the corresponding label. +/// - width (length): Chart width +/// - height (length): Chart height +/// - title (none, content): Optional chart title +/// - show-box (bool): Draw a small box-plot (median + IQR) inside each violin +/// - bandwidth (auto, float): KDE bandwidth; `auto` uses Silverman's rule +/// - samples (int): Number of evaluation points along the density curve +/// - show-grid (auto, bool): Draw background grid lines; `auto` uses theme default +/// - stroke-width (length): Stroke width for violin outlines and the median/IQR marks +/// - x-label (none, content): X-axis title +/// - y-label (none, content): Y-axis title +/// - annotations (none, array): Optional annotation descriptors +/// - theme (none, dictionary): Theme overrides +/// -> content +#let violin-plot( + data, + width: auto, + height: auto, + title: none, + show-box: true, + bandwidth: auto, + samples: 30, + show-grid: auto, + stroke-width: 1.2pt, + x-label: none, + y-label: none, + annotations: none, + theme: none, +) = context { + layout(size => { + validate-violin-data(data, "violin-plot") + let grid-overrides = if show-grid != auto { (show-grid: show-grid) } else { none } + let t = _resolve-ctx(theme, overrides: grid-overrides) + let (width, height) = resolve-size(width, height, size, n: data.labels.len(), theme: t) + let (labels, datasets) = (data.labels, data.datasets) + + let mean(arr) = arr.sum() / arr.len() + let std-dev(arr) = { + let m = mean(arr) + let variance = arr.map(v => (v - m) * (v - m)).sum() / arr.len() + if variance == 0 { 1.0 } else { calc.sqrt(variance) } + } + + // Median of a sorted array + let median-of(sorted) = { + let len = sorted.len() + let mid = calc.div-euclid(len, 2) + if calc.rem(len, 2) == 1 { sorted.at(mid) } else { (sorted.at(mid - 1) + sorted.at(mid)) / 2 } + } + + // Quartiles (Q1, median, Q3) of a sorted array + let quartiles(sorted) = { + let len = sorted.len() + let med = median-of(sorted) + let half = calc.div-euclid(len, 2) + let lower = sorted.slice(0, half) + let upper = if calc.rem(len, 2) == 1 { sorted.slice(half + 1) } else { sorted.slice(half) } + (q1: if lower.len() > 0 { median-of(lower) } else { med }, + median: med, + q3: if upper.len() > 0 { median-of(upper) } else { med }) + } + + // KDE of a sorted dataset: `(lo, hi)` extent and `(value, density)` samples. + // The curve runs 10% of the data range past the extremes, so its tails close, + // but not below zero when every observation is non-negative (no -20 on a score axis). + let compute-kde(ds) = { + let nn = ds.len() + // Silverman's rule of thumb + let bw = nonzero(if bandwidth != auto { bandwidth } else { 1.06 * std-dev(ds) * calc.pow(nn, -0.2) }, fallback: 1.0) + // (identical observations have no range: the tails get three bandwidths to close) + let ext = nonzero((ds.last() - ds.first()) * 0.1, fallback: 3 * bw) + let (lo, hi) = (ds.first() - ext, ds.last() + ext) + if ds.first() >= 0 { lo = calc.max(lo, 0) } + let step = (hi - lo) / (samples - 1) + // Density, up to the constant 1/sqrt(2 pi): relative magnitudes stay right across groups + let points = range(samples).map(si => { + let x = lo + si * step + (value: x, density: ds.map(v => calc.exp(-0.5 * calc.pow((x - v) / bw, 2))).sum() / (nn * bw)) + }) + (lo: lo, hi: hi, points: points, max-density: calc.max(..points.map(p => p.density))) + } + let kdes = datasets.map(ds => compute-kde(ds.sorted())) + let global-max-density = nonzero(calc.max(..kdes.map(k => k.max-density)), fallback: 1.0) + + cartesian-frame(width, height, t, + x: category-axis(labels, title: x-label), + y: value-axis(calc.min(..kdes.map(k => k.lo)), calc.max(..kdes.map(k => k.hi)), title: y-label), + title: title, annotations: annotations, + p => { + let (px, py) = (p.px, p.py) + // Widest half of a violin: 40% of its slot + let max-half-w = p.X.pitch * 0.4 + for (i, kde) in kdes.enumerate() { + let cx = px(i) + let color = get-color(t, i) + let half(d) = d / global-max-density * max-half-w + + // Right side top-to-bottom, then the left side back up + let right-pts = kde.points.map(pt => (cx + half(pt.density), py(pt.value))) + let left-pts = kde.points.map(pt => (cx - half(pt.density), py(pt.value))).rev() + place(left + top, polygon(fill: color.transparentize(30%), stroke: stroke-width + color, ..right-pts, ..left-pts)) + + // Inner box plot: an IQR bar with the median as a dot, in theme colours + if show-box { + let q = quartiles(datasets.at(i).sorted()) + let bar-w = calc.min(2pt, max-half-w * 0.2) + let (y-q1, y-med, y-q3) = (py(q.q1), py(q.median), py(q.q3)) + place(left + top, dx: cx - bar-w / 2, dy: y-q3, + rect(width: bar-w, height: y-q1 - y-q3, fill: t.text-color, stroke: none)) + place(left + top, dx: cx - bar-w * 1.1, dy: y-med - bar-w * 1.1, + circle(radius: bar-w * 1.1, fill: if t.background != none { t.background } else { white }, stroke: none)) + } + } + }, + ) + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/waffle.typ b/packages/preview/primaviz/0.12.0/src/charts/waffle.typ new file mode 100644 index 0000000000..4069b48cbc --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/waffle.typ @@ -0,0 +1,175 @@ +// waffle.typ - Waffle chart (grid of squares showing proportions) +#import "../theme.typ": _resolve-ctx, get-color +#import "../util.typ": normalize-data, nonzero +#import "../validate.typ": validate-simple-data +#import "../primitives/container.typ": chart-container +#import "../primitives/legend.typ": draw-legend-vertical +#import "../primitives/layout.typ": resolve-size, max-text-width, text-height, spread-labels, place-centered + +/// Renders a waffle chart — a grid of squares where each square represents +/// a unit or percentage of the total. Commonly used in infographics to +/// show proportions at a glance. +/// +/// Values are normalized to percentages if they don't sum to 100. +/// Squares fill left-to-right, bottom-to-top with category colors. +/// +/// - data (dictionary, array): Label-value pairs as dict or array of tuples +/// - size (length): Overall chart width and height for the grid area +/// - rows (int): Number of rows in the grid +/// - cols (int): Number of columns in the grid +/// - gap (length): Spacing between squares +/// - rounded (bool): Whether squares have rounded corners +/// - title (none, content): Optional chart title +/// - show-legend (bool): Name the categories and show their colors: in a column beside the +/// grid, each name level with its own band of squares, when that fits (at most 40% of the +/// room); otherwise in a legend +/// - show-values (bool): Display each category's share beside its name +/// - theme (none, dictionary): Theme overrides +/// - extra-legend-separation (length): Extra space between legend and chart +/// -> content +#let waffle-chart( + data, + size: 200pt, + rows: 10, + cols: 10, + gap: 2pt, + rounded: true, + title: none, + show-legend: true, + show-values: true, + theme: none, + extra-legend-separation: 0pt, +) = context { + layout(avail => { + validate-simple-data(data, "waffle-chart") + let t = _resolve-ctx(theme) + let size = resolve-size(size, size, avail).width + + let norm = normalize-data(data) + let labels = norm.labels + let values = norm.values + let n = labels.len() + let total-cells = rows * cols + let raw-sum = nonzero(values.sum()) + + // Legend entries, "name (share)": the legend is as wide as its longest + // entry, measured, and never more than 40% of the room + let percentages = values.map(v => calc.round(v / raw-sum * 100, digits: 1)) + let legend-entries = () + if show-legend { + for i in range(n) { + let pct = str(percentages.at(i)) + "%" + legend-entries.push(if show-values { labels.at(i) + " (" + pct + ")" } else { labels.at(i) }) + } + } + let avail-w = if type(avail.width) == length and avail.width > 0pt { avail.width } else { none } + // Names beside the grid, level with their bands, when they fit; else a legend + let lsz = t.legend-size + let sw = lsz * 0.8 + let direct-w = if show-legend and n > 0 { sw + 4pt + max-text-width(legend-entries, lsz) + 2pt } else { 0pt } + let lh = calc.max(sw, text-height("Mg", lsz)) + 3pt + let direct = show-legend and n > 0 and (avail-w == none or direct-w <= avail-w * 0.4) and n * lh <= size + let legend-w = if show-legend and n > 0 and not direct { + let natural = t.legend-swatch-size + 3pt + max-text-width(legend-entries, t.legend-size) + 2pt + if avail-w != none { calc.min(natural, avail-w * 0.4) } else { natural } + } else { 0pt } + + // Shrink the grid if grid + labels + container padding exceed the room + let legend-overhead = if direct { direct-w + 10pt } else if show-legend { legend-w + 10pt } else { 0pt } + let pad2 = 2 * t.container-inset + if avail-w != none and size + legend-overhead + pad2 > avail-w { + size = calc.max(20pt, avail-w - legend-overhead - pad2) + } + + // Normalize values to percentages of total-cells + let cell-counts = values.map(v => int(calc.round(v / raw-sum * total-cells))) + + // Adjust rounding so cell-counts sum to exactly total-cells + let allocated = cell-counts.sum() + if allocated != total-cells and n > 0 { + // Add or remove from the largest category + let max-idx = 0 + let max-val = cell-counts.at(0) + for i in range(1, n) { + if cell-counts.at(i) > max-val { + max-val = cell-counts.at(i) + max-idx = i + } + } + cell-counts.at(max-idx) = cell-counts.at(max-idx) + (total-cells - allocated) + } + + // Build a flat array mapping each cell index to a category index + // Cells fill bottom-to-top, left-to-right + let cell-colors = () + for (cat-idx, count) in cell-counts.enumerate() { + for _ in range(count) { + cell-colors.push(cat-idx) + } + } + + // Compute square size from available space + let cell-w = (size - gap * (cols - 1)) / cols + let cell-h = (size - gap * (rows - 1)) / rows + let cell-size = calc.min(cell-w, cell-h) + let radius = if rounded { 2pt } else { 0pt } + + // Compute actual grid dimensions + let grid-width = cell-size * cols + gap * (cols - 1) + let grid-height = cell-size * rows + gap * (rows - 1) + + // The squares: bottom-to-top, left-to-right + let cells = box(width: grid-width, height: grid-height)[ + #for row in range(rows) { + // bottom-to-top: row 0 is the bottom row visually + let visual-row = rows - 1 - row + for col in range(cols) { + let cell-idx = row * cols + col + let cat-idx = if cell-idx < cell-colors.len() { cell-colors.at(cell-idx) } else { 0 } + place( + dx: col * (cell-size + gap), + dy: visual-row * (cell-size + gap), + rect(width: cell-size, height: cell-size, fill: get-color(t, cat-idx), radius: radius), + ) + } + } + ] + + // Force legend to right side for waffle charts + let wt = (..t, legend-position: "right") + + if direct { + // Each name sits level with the middle of its band of squares; names of + // thin bands are spread apart so none overlaps another + let pitch = cell-size + gap + let row-of(idx) = rows - 1 - calc.div-euclid(calc.min(idx, total-cells - 1), cols) + let starts = array.range(n).map(k => cell-counts.slice(0, k).fold(0, (a, b) => a + b)) + let centres = array.range(n).map(k => { + let (a, b) = (starts.at(k), starts.at(k) + cell-counts.at(k)) + let top-row = row-of(calc.max(a, b - 1)) + let bot-row = row-of(a) + ((top-row + bot-row + 1) / 2) * pitch - gap / 2 + }) + let ys = spread-labels(centres, sizes: lh, lo: 0pt, hi: grid-height, priority: cell-counts) + let body-w = grid-width + 10pt + direct-w + align(center, chart-container(body-w, grid-height, title, wt, extra-legend-separation: extra-legend-separation)[ + #box(width: body-w, height: grid-height)[ + #place(left + top, cells) + #for k in range(n) { + let pct = str(percentages.at(k)) + "%" + if ys.at(k) != none { place-centered(grid-width + 10pt, ys.at(k), grid(columns: (auto, auto), column-gutter: 4pt, align: horizon, + rect(width: sw, height: sw, fill: get-color(t, k), radius: 1pt), + text(size: lsz, fill: t.text-color)[#labels.at(k)#if show-values [ #text(fill: t.text-color-light)[#pct]]])) } + } + ] + ]) + } else { + let legend-content = if show-legend and n > 0 { draw-legend-vertical(legend-entries, wt, width: legend-w) } else { none } + align(center, chart-container(size, grid-height, title, wt, legend: legend-content, legend-width: legend-w, extra-legend-separation: extra-legend-separation)[ + #box(width: size, height: grid-height)[ + #align(center + top)[#cells] + ] + ]) + } + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/waterfall.typ b/packages/preview/primaviz/0.12.0/src/charts/waterfall.typ new file mode 100644 index 0000000000..4ba96db33e --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/waterfall.typ @@ -0,0 +1,119 @@ +// waterfall.typ - Waterfall chart (bridge chart) +#import "../theme.typ": _resolve-ctx, get-color, _is-dark +#import "../util.typ": normalize-data +#import "../validate.typ": validate-simple-data +#import "../primitives/frame.typ": cartesian-frame +#import "../primitives/axes.typ": value-axis, category-axis, fit-label-size, value-text +#import "../primitives/legend.typ": draw-legend +#import "../primitives/layout.typ": resolve-size, pin + +/// Renders a waterfall (bridge) chart showing cumulative effect of positive and negative values. +/// +/// - data (dictionary, array): Label-value pairs; optionally include a `types` array with `"total"`, `"pos"`, or `"neg"` per bar +/// - width (length): Chart width +/// - height (length): Chart height +/// - title (none, content): Optional chart title +/// - show-values (bool): Display value labels above bars +/// - show-connectors (bool): Draw connector lines between bars +/// - bar-width (float): Bar width as fraction of slot (0 to 1) +/// - positive-color (none, color): Override color for positive bars +/// - negative-color (none, color): Override color for negative bars +/// - total-color (none, color): Override color for total bars +/// - x-label (none, content): X-axis title +/// - y-label (none, content): Y-axis title +/// - annotations (none, array): Optional annotation descriptors +/// - show-legend (bool): Show a legend of the positive, negative, and total bar colors +/// - theme (none, dictionary): Theme overrides +/// - extra-legend-separation (length): Extra space between legend and chart +/// -> content +#let waterfall-chart( + data, + width: auto, + height: auto, + title: none, + show-values: true, + show-connectors: true, + bar-width: 0.5, + positive-color: none, + negative-color: none, + total-color: none, + show-legend: false, + x-label: none, + y-label: none, + annotations: none, + theme: none, + extra-legend-separation: 0pt +) = context { + layout(size => { + validate-simple-data(data, "waterfall-chart") + let t = _resolve-ctx(theme) + let norm = normalize-data(data) + let (labels, values) = (norm.labels, norm.values) + let n = values.len() + let (width, height) = resolve-size(width, height, size, n: n, theme: t) + + // Resolve colors — check theme passthrough keys, then params, then defaults + let has-dark-bg = _is-dark(t) + let pos-color = if positive-color != none { positive-color } + else if "positive-color" in t { t.positive-color } + else if has-dark-bg { rgb("#4ade80") } else { rgb("#16a34a") } + let neg-color = if negative-color != none { negative-color } + else if "negative-color" in t { t.negative-color } + else if has-dark-bg { rgb("#f87171") } else { rgb("#dc2626") } + let tot-color = if total-color != none { total-color } else { get-color(t, 0) } + + // Types: auto-detect if not provided (first and last bars are totals) + let types = if type(data) == dictionary and "types" in data { + data.types + } else { + range(n).map(i => if i == 0 or i == n - 1 { "total" } else if values.at(i) >= 0 { "pos" } else { "neg" }) + } + let is-total(i) = types.at(i) == "total" or types.at(i) == "subtotal" + + // Each bar runs from `starts` to `ends`: totals from zero, steps from the running total + let (starts, ends) = ((), ()) + let running = 0 + for i in range(n) { + let from = if is-total(i) { 0 } else { running } + let to = if is-total(i) { values.at(i) } else { running + values.at(i) } + starts.push(from) + ends.push(to) + running = to + } + let all-points = starts + ends + (0,) + + cartesian-frame(width, height, t, + x: category-axis(labels, title: x-label), + y: value-axis(calc.min(..all-points), calc.max(..all-points), title: y-label), + baseline: (y: 0), annotations: annotations, + title: title, extra-legend-separation: extra-legend-separation, + legend: if show-legend { + draw-legend(((name: "Increase", color: pos-color), (name: "Decrease", color: neg-color), (name: "Total", color: tot-color)), t) + }, + reserve: (top: if show-values { t.value-label-size + t.label-offset } else { 0pt }), + p => { + let (px, py) = (p.px, p.py) + let bw = p.X.pitch * bar-width + let strs = values.map(v => value-text(v, t)) + let vsize = fit-label-size(strs, p.X.pitch, t.value-label-size) + for i in range(n) { + let (top-y, bottom-y) = (py(calc.max(starts.at(i), ends.at(i))), py(calc.min(starts.at(i), ends.at(i)))) + let fill-color = if is-total(i) { tot-color } else if values.at(i) >= 0 { pos-color } else { neg-color } + place(left + top, dx: px(i) - bw / 2, dy: top-y, + rect(width: bw, height: bottom-y - top-y, fill: fill-color, stroke: none)) + + if show-values and vsize != none { + place(left + top, dx: px(i) - p.X.pitch / 2, dy: top-y - t.label-offset, + pin(width: p.X.pitch, align: center + bottom, text(size: vsize, fill: t.text-color)[#strs.at(i)])) + } + + // Connector from this bar's end to the next step's start (totals start from zero instead) + if show-connectors and i < n - 1 and not is-total(i + 1) { + let y = py(ends.at(i)) + place(left + top, line(start: (px(i) + bw / 2, y), end: (px(i + 1) - bw / 2, y), stroke: t.grid-stroke)) + } + } + }, + ) + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/charts/wordcloud.typ b/packages/preview/primaviz/0.12.0/src/charts/wordcloud.typ new file mode 100644 index 0000000000..1a15aa3381 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/charts/wordcloud.typ @@ -0,0 +1,256 @@ +// wordcloud.typ - Word cloud chart (spiral placement with collision detection) +#import "../theme.typ": _resolve-ctx, get-color +#import "../util.typ": nonzero +#import "../validate.typ": validate-wordcloud-data +#import "../primitives/container.typ": chart-container +#import "../primitives/polar-labels.typ": legible +#import "../primitives/layout.typ": resolve-size + +// (the `color` parameter shadows the type inside the chart) +#let _mix = color.mix + +/// Renders a word cloud using spiral placement (Wordle-style algorithm). +/// +/// Words are placed largest-first, spiraling outward from the center. +/// Each word is tested for AABB collision with all previously placed words +/// and shape boundary containment before being committed. When the words do +/// not all fit, the font sizes shrink in proportion (down to the theme's +/// smallest type for every word) until they do; only words that still do not +/// fit then are left out, smallest first. +/// +/// Size says the weight, and so does colour: one hue, the theme's first +/// palette colour, from pale (light words) to full (heavy ones), and the +/// heavier half in bold. `color: auto` gives each word a colour of its own +/// from the palette instead (colour then says nothing), with the weight of +/// the type alternating. Colours too pale to read on the background are +/// darkened (or lightened on a dark one). +/// +/// - data (dictionary): Must contain a `words` array of dictionaries, +/// each with `text` (string) and `weight` (number) keys. +/// - width (length): Chart width +/// - height (length): Chart height +/// - min-size (length): Font size for the lowest-weight word +/// - max-size (length): Font size for the highest-weight word +/// - color (none, auto, color): `none` (default): the theme's first colour, paler for lighter words. A colour: that colour for every word. `auto`: a palette colour per word +/// - title (none, content): Optional chart title +/// - padding (length): Inner padding around the word area +/// - shape (str): Layout shape — `"rectangle"` (default), +/// `"circle"`, `"diamond"`, or `"triangle"` +/// - theme (none, dictionary): Theme overrides +/// -> content +#let word-cloud( + data, + width: auto, + height: auto, + min-size: 8pt, + max-size: 36pt, + color: none, + title: none, + padding: 8pt, + shape: "rectangle", + theme: none, +) = context { + layout(size => { + validate-wordcloud-data(data, "word-cloud") + let t = _resolve-ctx(theme) + let (width, height) = resolve-size(width, height, size, n: 10, theme: t) + let words = data.words + + if words.len() == 0 { return } + + // Sort by weight descending — largest words placed first + let sorted-words = words.sorted(key: w => -w.weight) + + let max-weight = sorted-words.at(0).weight + let min-weight = sorted-words.last().weight + let weight-range = nonzero(max-weight - min-weight) + + // Font weight alternation for visual variety + let font-weights = ("bold", "regular", "bold", "medium", "regular") + + let bg = if t.background != none { t.background } else { white } + let inner-w = width - 2 * padding + let inner-h = height - 2 * padding + + // Geometry runs in unitless points: the placement below is the inner loop + let (iw, ih) = (inner-w / 1pt, inner-h / 1pt) + + // Shape width at a given y-fraction (0 = top, 1 = bottom) + let shape-width-at(y-frac) = { + let dy = y-frac - 0.5 + if shape == "circle" { + let tt = 2 * dy + let sq = 1.0 - tt * tt + if sq <= 0 { 0.0 } else { iw * calc.sqrt(sq) } + } else if shape == "diamond" { + let tt = calc.abs(2 * dy) + iw * calc.max(0, 1 - tt) + } else if shape == "triangle" { + iw * y-frac + } else { + iw + } + } + + // Check if bbox (x, y, w, h) fits within shape boundary + let in-bounds(bx, by, bw, bh) = { + if bx < 0 or by < 0 or bx + bw > iw or by + bh > ih { + false + } else if shape == "rectangle" { + true + } else { + // All 4 corners must be inside the shape + let corners = ((bx, by), (bx + bw, by), (bx, by + bh), (bx + bw, by + bh)) + let mid = iw / 2 + corners.all(c => { + let yf = calc.max(0, calc.min(1, c.at(1) / ih)) + let half = shape-width-at(yf) / 2 + c.at(0) >= mid - half and c.at(0) <= mid + half + }) + } + } + + // Colour: what a word's colour says. By default one hue (the theme's first + // colour) from pale to full by weight, so colour repeats what size says; + // `color: auto` gives every word its own palette colour (it says nothing). + let base = if color == none or color == auto { get-color(t, 0) } else { color } + // (the heaviest words in ink, the lightest still readable at the smallest size) + let ink = legible(base, bg, min-ratio: 6.5) + let pale = legible(_mix((base, 50%), (bg, 50%), space: oklab), bg, min-ratio: 3.6) + let word-color(i, frac) = if color == auto { legible(get-color(t, i), bg) } + else if color != none { legible(color, bg) } + else { _mix((ink, frac * 100%), (pale, (1 - frac) * 100%), space: oklab) } + + // Size of the smallest word, at least: below this the cloud is not read + let size-floor = calc.min(min-size, t.axis-label-size) + + // Each word with what does not depend on the scale: where it stands in the + // weights, its type weight and colour, and its width and height per point of + // type size (a word's box grows in proportion to its type size, so one + // measurement serves every scale) + let proto = sorted-words.enumerate().map(((i, w)) => { + // (equal weights are all middle-sized, not all the smallest) + let frac = if max-weight == min-weight { 0.5 } else { (w.weight - min-weight) / weight-range } + // (the weight alternation is for `color: auto` only: otherwise the heavier half is bold) + let fw = if color == auto { font-weights.at(calc.rem(i, font-weights.len())) } + else if frac >= 0.5 { "bold" } else { "regular" } + let dims = measure(text(size: 10pt, weight: fw)[#w.text]) + (text: w.text, frac: frac, fw: fw, color: word-color(i, frac), em-w: dims.width / 10pt, em-h: dims.height / 10pt) + }) + let n-words = proto.len() + + // The words at a given `scale`, largest first: the size range, from the + // floor up, is scaled by `scale` (1 is what was asked for, 0 is every word + // at the floor). Sizes and boxes are unitless points. + let words-at(scale) = proto.map(w => { + let sz = (size-floor + (min-size + w.frac * (max-size - min-size) - size-floor) * scale) / 1pt + // (the room around a word is 8pt by 4pt, less for small type) + (: ..w, size: sz, w: w.em-w * sz + calc.min(8.0, sz * 0.4), h: w.em-h * sz + calc.min(4.0, sz * 0.2)) + }) + + // Spiral placement with AABB collision detection + // Each word spirals outward from center until a non-overlapping position + // is found. The spiral is an ellipse as wide as the canvas is, so a wide + // canvas fills from the middle outwards evenly, and its turns lie closer + // together for a small word (half a word's height apart) than for a big one. + // With `strict` the first word that finds no room ends it (the caller only + // wants to know whether everything fits). + let aspect = iw / calc.max(ih, 1) + let (cxf, cyf) = (iw / 2, ih / 2) + let max-spiral = cyf * 1.45 // (out to the corners) + let place-words(word-data, strict: false) = { + let placed = () + for (wi, wd) in word-data.enumerate() { + let (ww, wh) = (wd.w, wd.h) + let (half-w, half-h) = (ww / 2, wh / 2) + let found = false + let px = 0.0 + let py = 0.0 + let spiral-res = calc.max(0.15, wh / 48) + + // Golden angle offset per word for natural distribution + let offset = wi * 137.5 + + for step in range(3000) { + let theta = (step * 15 + offset) * 1deg // (24 points per revolution) + let r = step * spiral-res + if r > max-spiral { break } + + let try-x = cxf + r * aspect * calc.cos(theta) - half-w + let try-y = cyf + r * calc.sin(theta) - half-h + + // Check shape/bounds + if not in-bounds(try-x, try-y, ww, wh) { continue } + + // Check AABB overlap with all placed words + let clear = placed.all(p => + try-x + ww <= p.x or p.x + p.w <= try-x or + try-y + wh <= p.y or p.y + p.h <= try-y) + + if clear { + px = try-x + py = try-y + found = true + break + } + } + + if found { + placed.push((x: px, y: py, w: ww, h: wh, word: wd)) + } else if strict { + break + } + } + placed + } + + // Every word is wanted: shrink the cloud (big words first, in proportion) + // until all fit; only words that still do not fit with every word at the + // legible floor are left out, smallest first. The size asked for goes first + // if the words could fill that little of the shape. After that each try is + // sized by area, from how much of the shape a spiral typically fills, and + // the next one is smaller by the area that was too much. + let shape-area = iw * ih * (if shape == "circle" { 0.785 } else if shape == "rectangle" { 1.0 } else { 0.5 }) + let area-at(scale) = words-at(scale).map(w => w.w * w.h).sum() + let scale-for(area) = { + let best = 0.0 + for k in range(20, 0, step: -1) { + if area-at(k / 20) <= area { best = k / 20; break } + } + best + } + let placed = () + if area-at(1.0) <= 0.85 * shape-area { + placed = place-words(words-at(1.0), strict: true) + } + if placed.len() < n-words { + placed = () + let limit = shape-area * (if shape == "rectangle" { 0.6 } else if shape == "circle" { 0.55 } else { 0.42 }) + for _ in range(8) { + let scale = scale-for(limit) + let tried = place-words(words-at(scale), strict: scale > 0) + if scale == 0 or tried.len() == n-words { + placed = tried + break + } + limit = area-at(scale) * 0.85 + } + // (eight rounds did not reach the floor: whatever fits there) + if placed.len() == 0 { placed = place-words(words-at(0.0)) } + } + + // Render all placed words + chart-container(width, height, title, t)[ + #box(width: width, height: height, clip: true, inset: padding)[ + #for p in placed { + let wd = p.word + place(left + top, + dx: p.x * 1pt, dy: p.y * 1pt, + box(width: p.w * 1pt, height: p.h * 1pt, + align(center + horizon, + text(size: wd.size * 1pt, fill: wd.color, weight: wd.fw)[#wd.text]))) + } + ] + ] + }) +} diff --git a/packages/preview/primaviz/0.12.0/src/data.typ b/packages/preview/primaviz/0.12.0/src/data.typ new file mode 100644 index 0000000000..d138e05dc9 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/data.typ @@ -0,0 +1,232 @@ +// data.typ — Helpers for loading and reshaping JSON into chart-ready formats +// +// Usage: +// #import "@preview/primaviz:0.12.0": * +// #let raw = json("sales.json") +// #bar-chart(load-simple(raw)) + +/// Reshape a JSON object or array into simple `(labels, values)` format. +/// +/// Accepts: +/// - dict: `{"Jan": 300, "Feb": 303}` → labels from keys, values from values +/// - array of pairs: `[["Jan", 300], ["Feb", 303]]` +/// - array of objects: `[{"label": "Jan", "value": 300}, ...]` +/// - already-shaped: `{"labels": [...], "values": [...]}` (passed through) +/// +/// -> dictionary +#let load-simple(raw) = { + if type(raw) == dictionary { + // Already in our format? + if "labels" in raw and "values" in raw { + return raw + } + // Dict of key: value pairs + let keys = raw.keys() + let vals = keys.map(k => raw.at(k)) + (labels: keys, values: vals) + } else if type(raw) == array { + if raw.len() == 0 { return (labels: (), values: ()) } + let first = raw.at(0) + if type(first) == array { + // Array of [label, value] pairs + (labels: raw.map(p => str(p.at(0))), values: raw.map(p => p.at(1))) + } else if type(first) == dictionary { + // Array of {label: ..., value: ...} objects + let lkey = if "label" in first { "label" } else if "name" in first { "name" } else { "key" } + let vkey = if "value" in first { "value" } else { "val" } + (labels: raw.map(r => str(r.at(lkey))), values: raw.map(r => r.at(vkey))) + } else { + // Plain array of values — generate numeric labels + (labels: array.range(raw.len()).map(i => str(i)), values: raw) + } + } else { + panic("load-simple: expected dict or array, got " + str(type(raw))) + } +} + +/// Reshape JSON into multi-series `(labels, series)` format. +/// +/// Accepts: +/// - dict of dicts: `{"Q1": {"A": 10, "B": 20}, "Q2": {"A": 15, "B": 25}}` +/// → labels from outer keys, series from inner keys +/// - dict with `labels` + `series`: passed through +/// - array of objects: `[{"period": "Q1", "A": 10, "B": 20}, ...]` +/// with `label-key` specifying which field holds labels (default: first string field) +/// +/// -> dictionary +#let load-series(raw, label-key: none) = { + if type(raw) == dictionary { + if "labels" in raw and "series" in raw { return raw } + // Dict of dicts: outer keys = labels, inner keys = series names + let labels = raw.keys() + let first-inner = raw.at(labels.at(0)) + let series-names = first-inner.keys() + let series = series-names.map(name => ( + name: name, + values: labels.map(lbl => raw.at(lbl).at(name, default: 0)), + )) + (labels: labels, series: series) + } else if type(raw) == array { + if raw.len() == 0 { return (labels: (), series: ()) } + let first = raw.at(0) + // Find label key: explicit, or first string-valued field + let lk = if label-key != none { label-key } else { + let found = none + for k in first.keys() { + if type(first.at(k)) == str { found = k; break } + } + if found == none { first.keys().at(0) } else { found } + } + let labels = raw.map(r => str(r.at(lk))) + let series-keys = first.keys().filter(k => k != lk) + let series = series-keys.map(name => ( + name: name, + values: raw.map(r => r.at(name, default: 0)), + )) + (labels: labels, series: series) + } else { + panic("load-series: expected dict or array, got " + str(type(raw))) + } +} + +/// Reshape JSON into scatter `(x, y)` or `(x, y, labels)` format. +/// +/// Accepts: +/// - dict with `x` + `y` arrays: passed through +/// - array of objects: `[{"x": 1, "y": 2}, ...]` or `[{"x": 1, "y": 2, "label": "A"}, ...]` +/// - array of pairs/triples: `[[1, 2], [3, 4]]` +/// +/// -> dictionary +#let load-scatter(raw) = { + if type(raw) == dictionary { + if "x" in raw and "y" in raw { return raw } + panic("load-scatter: dict must have 'x' and 'y' keys") + } else if type(raw) == array { + if raw.len() == 0 { return (x: (), y: ()) } + let first = raw.at(0) + if type(first) == dictionary { + let result = (x: raw.map(r => r.at("x")), y: raw.map(r => r.at("y"))) + if "label" in first or "name" in first { + let lk = if "label" in first { "label" } else { "name" } + result.insert("labels", raw.map(r => str(r.at(lk)))) + } + result + } else if type(first) == array { + let result = (x: raw.map(p => p.at(0)), y: raw.map(p => p.at(1))) + if first.len() >= 3 { + result.insert("labels", raw.map(p => str(p.at(2)))) + } + result + } else { + panic("load-scatter: array elements must be dicts or arrays") + } + } else { + panic("load-scatter: expected dict or array") + } +} + +/// Reshape JSON into bubble `(x, y, size)` format. +/// +/// Like `load-scatter` but requires a size dimension. +/// +/// Accepts: +/// - dict with `x`, `y`, `size` arrays: passed through +/// - array of objects: `[{"x": 1, "y": 2, "size": 5}, ...]` +/// - array of triples: `[[1, 2, 5], [3, 4, 8]]` +/// +/// -> dictionary +#let load-bubble(raw) = { + if type(raw) == dictionary { + if "x" in raw and "y" in raw and "size" in raw { return raw } + panic("load-bubble: dict must have 'x', 'y', and 'size' keys") + } else if type(raw) == array { + if raw.len() == 0 { return (x: (), y: (), size: ()) } + let first = raw.at(0) + if type(first) == dictionary { + let result = ( + x: raw.map(r => r.at("x")), + y: raw.map(r => r.at("y")), + size: raw.map(r => r.at("size")), + ) + if "label" in first or "name" in first { + let lk = if "label" in first { "label" } else { "name" } + result.insert("labels", raw.map(r => str(r.at(lk)))) + } + result + } else if type(first) == array { + let result = ( + x: raw.map(p => p.at(0)), + y: raw.map(p => p.at(1)), + size: raw.map(p => p.at(2)), + ) + if first.len() >= 4 { + result.insert("labels", raw.map(p => str(p.at(3)))) + } + result + } else { + panic("load-bubble: array elements must be dicts or arrays") + } + } else { + panic("load-bubble: expected dict or array") + } +} + +/// Reshape JSON into hierarchical format for sunburst/treemap. +/// +/// Accepts: +/// - already-shaped: `{"name": "root", "value": 100, "children": [...]}` (passed through) +/// - flat array with parent refs: `[{"name": "A", "value": 10, "parent": null}, {"name": "B", "value": 5, "parent": "A"}]` +/// +/// -> dictionary +#let load-hierarchy(raw) = { + if type(raw) == dictionary { + if "name" in raw { return raw } + panic("load-hierarchy: dict must have 'name' key") + } else if type(raw) == array { + // Flat array with parent references → build tree + // First pass: index by name + let by-name = (:) + for item in raw { + let name = str(item.at("name")) + by-name.insert(name, ( + name: name, + value: item.at("value", default: 0), + children: (), + )) + } + // Second pass: attach children to parents + let roots = () + for item in raw { + let name = str(item.at("name")) + let parent = item.at("parent", default: none) + if parent == none or parent == "" { + roots.push(name) + } else { + let p = str(parent) + if p in by-name { + let node = by-name.at(p) + node.children = node.children + (by-name.at(name),) + by-name.insert(p, node) + } + } + } + // Recursive rebuild: re-resolve each node's children from by-name + // to pick up grandchildren etc. Typst supports recursive let-functions. + let rebuild(name) = { + let node = by-name.at(name) + if node.children.len() > 0 { + let resolved = node.children.map(c => rebuild(c.name)) + (name: node.name, value: node.value, children: resolved) + } else { + (name: node.name, value: node.value) + } + } + if roots.len() == 1 { + rebuild(roots.at(0)) + } else { + (name: "root", value: roots.map(r => by-name.at(r).value).sum(), children: roots.map(r => rebuild(r))) + } + } else { + panic("load-hierarchy: expected dict or array") + } +} diff --git a/packages/preview/primaviz/0.12.0/src/lib.typ b/packages/preview/primaviz/0.12.0/src/lib.typ new file mode 100644 index 0000000000..46ca8c7790 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/lib.typ @@ -0,0 +1,42 @@ +// primaviz - Public entrypoint +// Re-exports all chart types from individual modules + +#import "theme.typ": resolve-theme, get-color, default-theme, minimal-theme, dark-theme, presentation-theme, print-theme, accessible-theme, compact-theme, themes, with-theme, theme-from-json +#import "charts/bar.typ": bar-chart, horizontal-bar-chart, grouped-bar-chart, stacked-bar-chart, grouped-stacked-bar-chart +#import "charts/line.typ": line-chart, multi-line-chart +#import "charts/dual-axis.typ": dual-axis-chart +#import "charts/area.typ": area-chart, stacked-area-chart +#import "charts/pie.typ": pie-chart +#import "charts/radar.typ": radar-chart +#import "charts/scatter.typ": scatter-plot, multi-scatter-plot, bubble-chart, multi-bubble-chart +#import "charts/gauge.typ": gauge-chart, progress-bar, circular-progress, progress-bars +#import "charts/rings.typ": ring-progress +#import "charts/heatmap.typ": heatmap, calendar-heatmap, correlation-matrix +#import "charts/sparkline.typ": sparkline, sparkbar, sparkdot +#import "charts/funnel.typ": funnel-chart +#import "charts/waterfall.typ": waterfall-chart +#import "charts/boxplot.typ": box-plot +#import "charts/violin.typ": violin-plot +#import "charts/histogram.typ": histogram +#import "charts/treemap.typ": treemap +#import "charts/lollipop.typ": lollipop-chart, horizontal-lollipop-chart +#import "charts/sankey.typ": sankey-chart +#import "charts/chord.typ": chord-diagram +#import "charts/bullet.typ": bullet-chart, bullet-charts +#import "charts/slope.typ": slope-chart +#import "charts/dumbbell.typ": dumbbell-chart +#import "charts/diverging.typ": diverging-bar-chart +#import "charts/gantt.typ": gantt-chart +#import "charts/timeline.typ": timeline-chart +#import "charts/waffle.typ": waffle-chart +#import "charts/bump.typ": bump-chart +#import "charts/parliament.typ": parliament-chart +#import "charts/radial-bar.typ": radial-bar-chart +#import "charts/sunburst.typ": sunburst-chart +#import "charts/wordcloud.typ": word-cloud +#import "charts/metric.typ": metric-card, metric-row +#import "charts/dashboard.typ": card, compact-table, alert, badge, separator, dashboard-layout +#import "util.typ": sort-data, top-n, aggregate, percent-of-total +#import "data.typ": load-simple, load-series, load-scatter, load-bubble, load-hierarchy +#import "primitives/layout.typ": page-grid, resolve-size +#import "primitives/legend.typ": draw-gradient-legend, draw-size-legend diff --git a/packages/preview/primaviz/0.12.0/src/primitives/annotations.typ b/packages/preview/primaviz/0.12.0/src/primitives/annotations.typ new file mode 100644 index 0000000000..4e82787262 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/primitives/annotations.typ @@ -0,0 +1,308 @@ +// annotations.typ - Overlay annotations for cartesian charts + +#import "../theme.typ": * +#import "fonts.typ": spread-labels + +// Whether two `(x, y, w, h)` boxes overlap. +#let _hit(a, b) = a.x < b.x + b.w and b.x < a.x + a.w and a.y < b.y + b.h and b.y < a.y + a.h + +// A full line of text of `size` (ascender to descender). +#let _line-h(size) = measure(text(size: size, top-edge: "ascender", bottom-edge: "descender")[Ag]).height + +/// Room annotations need outside the plot: `right` for the labels beside +/// horizontal lines, `top` for those above vertical lines (a line of text and a +/// little air, so the label stays clear of the chart title above the plot). +/// +/// - annotations (none, array): Annotation descriptors +/// - theme (dictionary): Resolved theme +/// -> dictionary +#let annotation-reserve(annotations, theme) = { + let (right, top) = (0pt, 0pt) + let size = theme.axis-label-size * 0.85 + if annotations != none { + for ann in annotations { + let label = ann.at("label", default: none) + let kind = ann.at("type", default: "h-line") + if kind == "h-line" and label != none { + right = calc.max(right, measure(text(size: size)[#label]).width + 5pt) + } else if kind == "v-line" and label != none { + top = calc.max(top, _line-h(size) + 3pt) + } + } + } + (right: right, top: top) +} + +// Draw annotations on a cartesian chart. +// Annotations are drawn relative to the chart coordinate system. +// Text that would land on other text moves aside: the labels of horizontal lines +// slide along the plot's edge, those of vertical lines along its top, and `label` and +// `content` annotations shift up or down to the nearest free spot (a `label` keeps a +// leader line to its dot). +// Parameters: +// annotations: array of annotation dicts +// x-start: pixel x of chart area left edge +// y-start: pixel y of chart area top edge +// chart-width: pixel width of chart area +// chart-height: pixel height of chart area +// x-min, x-max: data range for x axis (for numeric) or 0..n-1 for categories +// y-min, y-max: data range for y axis +// theme: resolved theme +#let draw-annotations(annotations, x-start, y-start, chart-width, chart-height, x-min, x-max, y-min, y-max, theme) = { + if annotations == none { return } + + let x-range = x-max - x-min + let y-range = y-max - y-min + if x-range == 0 { x-range = 1 } + if y-range == 0 { y-range = 1 } + + // Helper: data coords to pixel coords + let to-px(data-x, data-y) = { + let px = x-start + ((data-x - x-min) / x-range) * chart-width + let py = y-start + chart-height - ((data-y - y-min) / y-range) * chart-height + (px, py) + } + + let small = theme.axis-label-size * 0.85 + let kind-of(ann) = ann.at("type", default: "h-line") + let color-of(ann) = ann.at("color", default: theme.text-color-light) + let small-label(ann, key: "label") = text(size: small, fill: color-of(ann))[#ann.at(key)] + let right-edge = x-start + chart-width + + // ── Where each piece of text goes, before anything is drawn ───────────────── + // Labels beside horizontal lines: spread along the edge so neighbours keep clear + let h-idx = annotations.enumerate().filter(((_, a)) => kind-of(a) == "h-line" and a.at("label", default: none) != none).map(((i, _)) => i) + let h-top = (:) // annotation index -> top of its label + if h-idx.len() > 0 { + let heights = h-idx.map(i => _line-h(small)) + let cap = h-idx.map(i => measure(small-label(annotations.at(i))).height) + let centres = h-idx.enumerate().map(((k, i)) => to-px(0, annotations.at(i).value).at(1) - 5pt + cap.at(k) / 2) + let spread = spread-labels(centres, sizes: heights, lo: y-start - 3pt, hi: y-start + chart-height + 3pt) + for (k, i) in h-idx.enumerate() { if spread.at(k) != none { h-top.insert(str(i), spread.at(k) - cap.at(k) / 2) } } + } + // Labels above vertical lines: spread along the top, kept inside the plot's width + let v-idx = annotations.enumerate().filter(((_, a)) => kind-of(a) == "v-line" and a.at("label", default: none) != none).map(((i, _)) => i) + let v-left = (:) // annotation index -> left edge of its label + if v-idx.len() > 0 { + let widths = v-idx.map(i => measure(small-label(annotations.at(i))).width) + let centres = v-idx.enumerate().map(((k, i)) => { + let px = to-px(annotations.at(i).value, 0).at(0) + calc.max(x-start + widths.at(k) / 2, calc.min(px, right-edge - widths.at(k) / 2)) + }) + let spread = spread-labels(centres, sizes: widths, gap: 3pt, lo: x-start, hi: right-edge) + for (k, i) in v-idx.enumerate() { if spread.at(k) != none { v-left.insert(str(i), spread.at(k) - widths.at(k) / 2) } } + } + + // Free-standing text (`label`, `content`): boxes as first placed, then moved up or + // down, nearest spot first, to a place clear of the other text and inside the plot + let box-of(ann) = { + let kind = kind-of(ann) + if kind == "label" { + let (px, py) = to-px(ann.x, ann.y) + let lbl = text(size: theme.axis-label-size, weight: "bold")[#ann.text] + let (w, h) = (measure(lbl).width, measure(lbl).height * 1.45) + let flip = px + 3pt + w > right-edge and px - 3pt - w >= x-start + (x: if flip { px - 3pt - w } else { px + 3pt }, y: py - 8pt, w: w, h: h, flip: flip) + } else if kind == "content" { + let (px, py) = to-px(ann.x, ann.y) + let sz = measure(ann.at("body", default: [])) + let anchor = ann.at("anchor", default: "center") + let adjust-x = if anchor.ends-with("right") { -sz.width } + else if anchor == "top" or anchor == "center" or anchor == "bottom" { -sz.width / 2 } + else { 0pt } + let adjust-y = if anchor.starts-with("bottom") { -sz.height } + else if anchor == "left" or anchor == "center" or anchor == "right" { -sz.height / 2 } + else { 0pt } + (x: px + ann.at("dx", default: 0pt) + adjust-x, y: py + ann.at("dy", default: 0pt) + adjust-y, w: sz.width, h: sz.height, flip: false) + } + } + let fixed = () // text that stays where it is: labels of points, bands and rectangles + for ann in annotations { + let kind = kind-of(ann) + if ann.at("label", default: none) == none { continue } + let lbl = small-label(ann) + let (w, h) = (measure(lbl).width, measure(lbl).height * 1.45) + if kind == "point" { + let (px, py) = to-px(ann.x, ann.y) + let radius = ann.at("radius", default: 3pt) + let flip = px + radius + 2pt + w > right-edge and px - radius - 2pt - w >= x-start + fixed.push((x: if flip { px - radius - 2pt - w } else { px + radius + 2pt }, y: py - 6pt, w: w, h: h)) + } else if kind == "h-band" { + let band-y = calc.min(to-px(0, ann.from).at(1), to-px(0, ann.to).at(1)) + fixed.push((x: x-start + 3pt, y: band-y + 2pt, w: w, h: h)) + } else if kind == "rect" { + let (px1, py1) = to-px(ann.x1, ann.y1) + let (px2, py2) = to-px(ann.x2, ann.y2) + fixed.push((x: calc.min(px1, px2) + 3pt, y: calc.min(py1, py2) + 2pt, w: w, h: h)) + } + } + let shift = (:) // annotation index -> vertical shift of its text + let placed = fixed + for (i, ann) in annotations.enumerate() { + if kind-of(ann) not in ("label", "content") { continue } + let b = box-of(ann) + let step = b.h + 1.5pt + let best = (cost: 1000000, dy: 0pt) + for (c, k) in (0, 1, -1, 2, -2, 3, -3).enumerate() { + let r = (x: b.x, y: b.y + k * step, w: b.w, h: b.h) + if r.y < y-start - 6pt or r.y + r.h > y-start + chart-height + 6pt { continue } + let cost = 100 * placed.filter(o => _hit(o, r)).len() + c + if cost < best.cost { best = (cost: cost, dy: k * step) } + if cost < 100 { break } + } + placed.push((x: b.x, y: b.y + best.dy, w: b.w, h: b.h)) + shift.insert(str(i), best.dy) + } + + // ── Drawing ───────────────────────────────────────────────────────────────── + for (idx, ann) in annotations.enumerate() { + let ann-type = kind-of(ann) + let ann-color = color-of(ann) + let ann-stroke-width = ann.at("stroke", default: 1pt) + let ann-dash = ann.at("dash", default: none) + let ann-label = ann.at("label", default: none) + + let stroke-style = if ann-dash == "dashed" { + (paint: ann-color, thickness: ann-stroke-width, dash: "dashed") + } else if ann-dash == "dotted" { + (paint: ann-color, thickness: ann-stroke-width, dash: "dotted") + } else { + ann-stroke-width + ann-color + } + + if ann-type == "h-line" { + let value = ann.value + let (_, py) = to-px(0, value) + place(left + top, + line(start: (x-start, py), end: (x-start + chart-width, py), stroke: stroke-style) + ) + if ann-label != none and str(idx) in h-top { + let lbl-top = h-top.at(str(idx)) + // A label that had to move off its line keeps a leader to it + if calc.abs(lbl-top - (py - 5pt)) > 1.5pt { + place(left + top, line(start: (right-edge, py), end: (right-edge + 3pt, lbl-top + measure(small-label(ann)).height / 2), + stroke: 0.5pt + ann-color)) + } + place(left + top, dx: right-edge + 3pt, dy: lbl-top, small-label(ann)) + } + } else if ann-type == "v-line" { + let value = ann.value + let (px, _) = to-px(value, 0) + place(left + top, + line(start: (px, y-start), end: (px, y-start + chart-height), stroke: stroke-style) + ) + if ann-label != none and str(idx) in v-left { + // Over the line (or as near as its neighbours allow), in the room above the plot + let lbl = text(size: small, fill: ann-color, top-edge: "ascender", bottom-edge: "descender")[#ann-label] + let (w, lh) = (measure(lbl).width, measure(lbl).height) + let x0 = v-left.at(str(idx)) + if calc.abs(x0 + w / 2 - px) > 2pt { + place(left + top, line(start: (px, y-start), end: (calc.max(x0, calc.min(px, x0 + w)), y-start - 2pt), + stroke: 0.5pt + ann-color)) + } + place(left + top, dx: x0, dy: y-start - lh - 2pt, lbl) + } + } else if ann-type == "h-band" { + let from-val = ann.from + let to-val = ann.to + let opacity = ann.at("opacity", default: 15%) + let (_, py-from) = to-px(0, from-val) + let (_, py-to) = to-px(0, to-val) + let band-y = calc.min(py-from, py-to) + let band-h = calc.abs(py-from - py-to) + place(left + top, dx: x-start, dy: band-y, + rect(width: chart-width, height: band-h, fill: ann-color.transparentize(100% - opacity), stroke: none) + ) + if ann-label != none { + place(left + top, dx: x-start + 3pt, dy: band-y + 2pt, small-label(ann)) + } + } else if ann-type == "label" { + let (px, py) = to-px(ann.x, ann.y) + let lbl = text(size: theme.axis-label-size, fill: ann-color, weight: "bold")[#ann.text] + let b = box-of(ann) + let dy = shift.at(str(idx)) + // Beside the dot, on its left when it would run past the plot's right edge; a + // label moved clear of other text keeps a leader line to its dot + if dy != 0pt { + place(left + top, line( + start: (px, py), end: (if b.flip { b.x + b.w + 1pt } else { b.x - 1pt }, b.y + dy + measure(lbl).height / 2 + 1pt), + stroke: 0.5pt + ann-color)) + } + place(left + top, dx: b.x, dy: b.y + dy, lbl) + // Small dot at the point + place(left + top, dx: px - 2pt, dy: py - 2pt, + circle(radius: 2pt, fill: ann-color) + ) + } else if ann-type == "content" { + // Place arbitrary Typst content at data coordinates (x, y). + // Anchor defaults to center; supported: top-left, top, top-right, + // left, center, right, bottom-left, bottom, bottom-right. + // The anchor names the content's own anchor point that lands on (x, y). + let b = box-of(ann) + place(left + top, dx: b.x, dy: b.y + shift.at(str(idx)), ann.at("body", default: [])) + } else if ann-type == "point" { + // Circle marker at data coordinates — useful for outliers, highlights. + let (px, py) = to-px(ann.x, ann.y) + let radius = ann.at("radius", default: 3pt) + let fill-color = ann.at("fill", default: ann-color) + let stroke-val = ann.at("stroke", default: none) + place(left + top, dx: px - radius, dy: py - radius, + circle(radius: radius, fill: fill-color, stroke: stroke-val) + ) + if ann-label != none { + let lbl = small-label(ann) + let w = measure(lbl).width + let flip = px + radius + 2pt + w > right-edge and px - radius - 2pt - w >= x-start + place(left + top, dx: if flip { px - radius - 2pt - w } else { px + radius + 2pt }, dy: py - 6pt, lbl) + } + } else if ann-type == "errorbar" { + // Error bar at anchor with cap lines at each end. + // Vertical (default): anchor `x` in data space, `low`/`high` in y data space. + // Horizontal (orientation: "h"): anchor `y`, `low`/`high` in x data space. + let orientation = ann.at("orientation", default: "v") + let cap-w = ann.at("cap-width", default: 6pt) + if orientation == "h" { + let (px-lo, py) = to-px(ann.low, ann.y) + let (px-hi, _) = to-px(ann.high, ann.y) + place(left + top, + line(start: (px-lo, py), end: (px-hi, py), stroke: stroke-style) + ) + place(left + top, + line(start: (px-lo, py - cap-w / 2), end: (px-lo, py + cap-w / 2), stroke: stroke-style) + ) + place(left + top, + line(start: (px-hi, py - cap-w / 2), end: (px-hi, py + cap-w / 2), stroke: stroke-style) + ) + } else { + let (px, py-lo) = to-px(ann.x, ann.low) + let (_, py-hi) = to-px(ann.x, ann.high) + place(left + top, + line(start: (px, py-lo), end: (px, py-hi), stroke: stroke-style) + ) + place(left + top, + line(start: (px - cap-w / 2, py-lo), end: (px + cap-w / 2, py-lo), stroke: stroke-style) + ) + place(left + top, + line(start: (px - cap-w / 2, py-hi), end: (px + cap-w / 2, py-hi), stroke: stroke-style) + ) + } + } else if ann-type == "rect" { + // Rectangle in data coordinates from (x1, y1) to (x2, y2). + let (px1, py1) = to-px(ann.x1, ann.y1) + let (px2, py2) = to-px(ann.x2, ann.y2) + let rx = calc.min(px1, px2) + let ry = calc.min(py1, py2) + let rw = calc.abs(px2 - px1) + let rh = calc.abs(py2 - py1) + let opacity = ann.at("opacity", default: 20%) + let fill-color = ann.at("fill", default: ann-color.transparentize(100% - opacity)) + let stroke-val = ann.at("stroke", default: none) + place(left + top, dx: rx, dy: ry, + rect(width: rw, height: rh, fill: fill-color, stroke: stroke-val) + ) + if ann-label != none { + place(left + top, dx: rx + 3pt, dy: ry + 2pt, small-label(ann)) + } + } + } +} diff --git a/packages/preview/primaviz/0.12.0/src/primitives/axes.typ b/packages/preview/primaviz/0.12.0/src/primitives/axes.typ new file mode 100644 index 0000000000..b0bff595b7 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/primitives/axes.typ @@ -0,0 +1,470 @@ +// axes.typ - Axis specs, tick selection, label policy and scales + +#import "../theme.typ": * +#import "../util.typ": format-number, nice-ticks + +// ── Axis specs ────────────────────────────────────────────────────────────── +// +// An axis is described once (`value-axis`, `category-axis`), resolved against +// the room it gets (`resolve-*`), and from then on every consumer — scale, grid, +// tick marks, tick labels, annotations — reads the same resolved record. Nothing +// re-derives ticks, so grid lines, tick marks and labels cannot disagree. + +/// A numeric axis over the data range `lo` to `hi`, snapped outward to a nice +/// tick list. +/// +/// - lo (int, float): Lowest value to cover +/// - hi (int, float): Highest value to cover +/// - title (none, content): Axis title +/// - color (auto, color): Tick label and title color; `auto` uses the theme text color +/// - inset (length): Room kept free at both ends of the axis, so marks centred on +/// the extreme values (points, bubbles) stay inside the plot. The ticks stay at +/// their values; the axis line moves out by `inset`. +/// - ticks (auto, dictionary): A ready `nice-ticks`-style record (`min`, `max`, +/// `ticks`, `step`, `digits`, optional `labels`) to use instead of picking one +/// - labelled (bool): Draw the tick labels (the axis keeps its marks and grid) +/// - pad (dictionary): `(lo: length, hi: length)`: room the axis must keep beyond +/// the extreme values, for labels set past the end of a bar. The axis range +/// widens (to the next nice tick) until that room is there. +/// - marks (array): `(value, radius)` pairs: marks centred on `value` that reach +/// `radius` to either side (bubbles). The axis range widens (to the next nice +/// tick) until every mark lies inside it, so the axis line, the ticks and the +/// plot extent stay one and the same. +/// -> dictionary +#let value-axis(lo, hi, title: none, color: auto, inset: 0pt, ticks: auto, labelled: true, pad: (:), marks: ()) = ( + kind: "value", lo: lo, hi: hi, title: title, color: color, inset: inset, ticks: ticks, labelled: labelled, + pad: pad, marks: marks, +) + +/// A categorical axis with one label per category. +/// +/// `mode: "slots"` gives every category an equal slot and centres its mark in it +/// (bars). `mode: "points"` spreads marks evenly with just enough room at the +/// ends for the end labels (lines). Labels that do not fit flat are all kept +/// as long as that is possible at a legible size: tilted 45 degrees or upright, +/// whichever keeps the larger type (down to the theme's `category-label-min-size`, +/// 6pt by default), and only then thinned (see `resolve-category-axis`). `overflow: "thin"` +/// skips the tilting and shows every k-th flat label; `rotate` and `auto` both tilt. +/// +/// - labels (array): Category labels (strings, numbers or content) +/// - title (none, content): Axis title +/// - mode (str): `"slots"` or `"points"` +/// - overflow (auto, str): `"rotate"` or `"thin"` +/// - inset (length): Minimum room at the ends in `"points"` mode (marker radius) +/// - labelled (bool): Draw the tick labels (the axis keeps its marks and grid) +/// -> dictionary +#let category-axis(labels, title: none, mode: "slots", overflow: auto, inset: 0pt, labelled: true) = ( + kind: "category", labels: labels, title: title, mode: mode, + overflow: overflow, inset: inset, color: auto, labelled: labelled, +) + +// ── Text metrics ──────────────────────────────────────────────────────────── + +// A tick label as drawn. Metrics below measure exactly this. +#let label-text(body, t, fill: auto, size: auto) = text( + size: if size == auto { t.axis-label-size } else { size }, fill: if fill == auto { t.text-color } else { fill }, +)[#body] + +// `label` cut to its longest prefix, closed with an ellipsis, for which +// `fits(candidate)` holds. A label that already fits, and any label that is not +// a string, is returned as it is. +#let _clip-label(label, fits) = { + if type(label) != str or fits(label) { return label } + let cs = label.clusters() + let (lo, hi, best) = (1, cs.len() - 1, 1) + while lo <= hi { + let mid = calc.floor((lo + hi) / 2) + if fits(cs.slice(0, mid).join() + "…") { best = mid; lo = mid + 1 } else { hi = mid - 1 } + } + cs.slice(0, best).join().trim() + "…" +} + +// Full line box (ascender to descender) of a tick label. +#let label-line-height(t) = measure(text(size: t.axis-label-size, top-edge: "ascender", bottom-edge: "descender")[Ag]).height + +// Height of a row of horizontal labels: cap height plus room for descenders. +#let label-row-height(t) = measure(text(size: t.axis-label-size)[0]).height + 0.3 * t.axis-label-size + +// Width of the widest of `strs` set as tick labels. +#let widest-label(strs, t) = strs.fold(0pt, (m, s) => calc.max(m, measure(label-text(s, t)).width)) + +// ── Value axes ────────────────────────────────────────────────────────────── + +// Tick labels for a tick record, formatted once for the whole axis. +#let _tick-labels(nt, t) = { + if "labels" in nt { return nt.labels } + let td = t.at("tick-digits", default: auto) + let digits = if td != auto { td } else { nt.digits } + // One notation for the whole axis: once any tick needs SI (10k and up), all use it + let big = nt.ticks.fold(0, (m, v) => calc.max(m, calc.abs(v))) >= 10000 + let mode = if t.number-format == "auto" and big { "si" } else { t.number-format } + nt.ticks.map(v => format-number(v, digits: digits, mode: mode, step: nt.step)) +} + +// Distance needed between neighbouring tick labels so they stay clear of each other +// (`tight`: a little closer, still with a clear gap). +#let _label-need(nt, vertical, t, tight: false) = if vertical { + measure(text(size: t.axis-label-size)[0]).height * if tight { 2.1 } else { 2.6 } +} else if tight { + widest-label(_tick-labels(nt, t), t) * 1.2 + 0.7 * t.axis-label-size +} else { + widest-label(_tick-labels(nt, t), t) * 1.4 + t.axis-label-size +} + +// Whether the ticks of `nt` spread over `room` leave their labels clear of each other. +#let _ticks-fit(nt, room, vertical, t, tight: false) = { + let n = nt.ticks.len() + n < 2 or room / (n - 1) >= _label-need(nt, vertical, t, tight: tight) +} + +// Largest nice tick list (up to theme.tick-count targets) whose labels fit. +#let _pick-ticks(lo, hi, room, vertical, t, most: auto) = { + let nt = nice-ticks(lo, hi, count: 2) + for c in range(calc.max(if most == auto { t.tick-count } else { most }, 2), 1, step: -1) { + nt = nice-ticks(lo, hi, count: c) + if _ticks-fit(nt, room, vertical, t) { break } + } + nt +} + +// Ticks for an axis whose marks (bubbles) reach past their values. For each nice step +// (1, 2 or 5 times a power of ten) the range runs from the first tick below the data to +// the first tick above it, then grows by single steps at whichever end a mark is still +// short of room. The steps that hold every mark, with labels that stay clear of each +// other and no more than `2 * tick-count + 3` ticks, compete on the length of the range +// (labels set a little closer than usual count as 25% longer): the shortest wins, and +// among near-equal ranges the one nearest the usual tick count. A range is only ever as +// wide as its marks need, never a step more. `none` when no step manages. +#let _cover-ticks(ax, room, vertical, t) = { + let (pad-lo, pad-hi) = (ax.pad.at("lo", default: 0pt), ax.pad.at("hi", default: 0pt)) + let (lo, hi) = (ax.lo, ax.hi) + if lo == hi { + // A flat extent gets a band around the value, as `nice-ticks` does + let pad = if lo == 0 { 1 } else { calc.abs(lo) * 0.1 } + (lo, hi) = (lo - pad, hi + pad) + } + let cap = 2 * t.tick-count + 3 + let e0 = calc.floor(calc.log(hi - lo, base: 10)) + let found = () + for e in range(e0 - 2, e0 + 2) { + for m in (1, 2, 5) { + let step = calc.round(m * calc.pow(10, e), digits: 12) + let a = calc.round(calc.floor(lo / step + 1e-9) * step, digits: 10) + let b = calc.round(calc.ceil(hi / step - 1e-9) * step, digits: 10) + if b == a { b = a + step } + let ok = false + for _ in range(2 * cap) { + let span = b - a + let (short-lo, short-hi) = (pad-lo - (ax.lo - a) / span * room, pad-hi - (b - ax.hi) / span * room) + for (v, r) in ax.marks { + short-lo = calc.max(short-lo, r - (v - a) / span * room) + short-hi = calc.max(short-hi, r - (b - v) / span * room) + } + if short-lo < 0.5pt and short-hi < 0.5pt { + ok = true + break + } + if short-lo >= 0.5pt { a = calc.round(a - step, digits: 10) } + if short-hi >= 0.5pt { b = calc.round(b + step, digits: 10) } + if (b - a) / step > cap { break } + } + let n = int(calc.round((b - a) / step)) + 1 + if ok and n >= 2 and n <= cap { + let nt = nice-ticks(a, b, step: step) + let plain = nice-ticks(a, b, count: t.tick-count).step == step + if _ticks-fit(nt, room, vertical, t) { found.push((nt: nt, span: b - a, n: n, plain: plain)) } + else if n <= t.tick-count + 3 and _ticks-fit(nt, room, vertical, t, tight: true) { + found.push((nt: nt, span: 1.25 * (b - a), n: n, plain: plain)) + } + } + } + } + if found.len() == 0 { return none } + let shortest = found.map(c => c.span).fold(found.first().span, calc.min) + found.filter(c => c.span <= shortest * 1.02).sorted(key: c => (not c.plain, calc.abs(c.n - t.tick-count))).first().nt +} + +/// Resolves a value axis for `extent` of room: picks the tick list (once) and +/// formats its labels. +/// +/// - ax (dictionary): From `value-axis` +/// - extent (length): Length of the axis +/// - vertical (bool): Whether the axis runs vertically (labels stack, not side by side) +/// - t (dictionary): Resolved theme +/// -> dictionary +#let resolve-value-axis(ax, extent, vertical, t) = { + let room = extent - 2 * ax.inset + let (pad-lo, pad-hi) = (ax.pad.at("lo", default: 0pt), ax.pad.at("hi", default: 0pt)) + // Widens the range by what the ends are short of (a few rounds: the ticks snap + // outward). Returns the ticks and whether every mark and pad found its room. + let widen(most) = { + let nt = _pick-ticks(ax.lo, ax.hi, room, vertical, t, most: most) + let ok = false + for _ in range(6) { + let span = nt.max - nt.min + let (short-lo, short-hi) = (pad-lo - (ax.lo - nt.min) / span * room, pad-hi - (nt.max - ax.hi) / span * room) + for (v, r) in ax.marks { + short-lo = calc.max(short-lo, r - (v - nt.min) / span * room) + short-hi = calc.max(short-hi, r - (nt.max - v) / span * room) + } + if short-lo < 0.5pt and short-hi < 0.5pt { + ok = true + break + } + nt = _pick-ticks( + nt.min - calc.max(short-lo, 0pt) / room * span, nt.max + calc.max(short-hi, 0pt) / room * span, + room, vertical, t, most: most) + } + (nt, ok) + } + let covered = if ax.ticks == auto and ax.marks.len() > 0 { _cover-ticks(ax, room, vertical, t) } + let nt = if covered != none { covered } else if ax.ticks == auto { + // (marks no step could hold fall back to widening the range until they fit) + let (nt, ok) = widen(auto) + if ax.marks.len() > 0 { + for most in (t.tick-count + 2, t.tick-count + 4) { + let (alt, alt-ok) = widen(most) + if alt-ok and alt.ticks.len() <= 2 * t.tick-count + 3 and (not ok or alt.max - alt.min < (nt.max - nt.min) * 0.9) { + (nt, ok) = (alt, alt-ok) + } + } + } + nt + } else if ax.ticks.at("thin", default: false) { + // A fixed tick list (histogram bin edges) shows every k-th tick: the + // fewest that leaves the labels clear of each other + let all = ax.ticks.ticks + let per = room / calc.max(all.len() - 1, 1) + let k = calc.max(1, calc.ceil(_label-need(ax.ticks, vertical, t) / per)) + (..ax.ticks, ticks: all.enumerate().filter(((i, _)) => calc.rem(i, k) == 0).map(((_, v)) => v)) + } else { ax.ticks } + (..ax, nt: nt, labels: _tick-labels(nt, t), dlo: nt.min, dhi: nt.max, block: label-row-height(t)) +} + +/// Resolves two vertical value axes that share one set of grid lines: finds a +/// tick count for which both land on the same number of ticks, so every tick of +/// one axis lines up with a tick of the other. Falls back to independent ticks +/// when no common count exists (`aligned: false`). +/// +/// -> array (left axis, right axis) +#let resolve-value-pair(a, b, extent, t) = { + let tc = calc.max(t.tick-count, 2) + for c in (..range(tc, 1, step: -1), ..range(tc + 1, tc + 4)) { + let na = nice-ticks(a.lo, a.hi, count: c) + let nb = nice-ticks(b.lo, b.hi, count: c) + if na.ticks.len() == nb.ticks.len() and _ticks-fit(na, extent - 2 * a.inset, true, t) { + return ( + resolve-value-axis((..a, ticks: na), extent, true, t), + resolve-value-axis((..b, ticks: nb), extent, true, t), + ) + } + } + (resolve-value-axis(a, extent, true, t), resolve-value-axis(b, extent, true, t)) +} + +// ── Category axes ─────────────────────────────────────────────────────────── + +/// Resolves a horizontal category axis over `len`: slot geometry and the label +/// policy. Every label is shown whenever that is physically possible at a legible +/// size, and thins only at the very end: +/// +/// 1. flat, at full size, when it fits; +/// 2. otherwise the tilted layout (45 degrees, or upright) that holds every label in +/// the largest type, down to a floor; at equal size the less rotated one; +/// 3. when even the floor does not hold them all, upright at the floor, thinned +/// evenly (first and last always kept). +/// +/// The floor is the theme key `category-label-min-size` (6pt unless set; never +/// above the axis label size). `overflow: "thin"` skips the tilted steps and keeps +/// the fewest flat labels that stay clear of each other, measured pair by pair +/// (one long label does not thin the short ones). Tilted labels never take more +/// than `max-block` of height: longer text is cut with an ellipsis. +/// +/// Adds `n`, `pitch` (distance between marks), `inset`, `dlo`/`dhi` (index +/// domain), `rotate` (whether tilted), `angle` (0, 45 or 90), `size` (type size +/// of the labels), `skip`, `shown` (indices of the labels drawn), `texts` (the +/// labels as drawn, clipped where needed), `block` (height the label row needs) +/// and `over-left` (how far a tilted first label reaches left of the plot). +#let resolve-category-axis(ax, len, t, max-block: none) = { + let n = ax.labels.len() + let size0 = t.axis-label-size + let width-of(l) = measure(label-text(l, t)).width + let natural = ax.labels.map(width-of) + let lh0 = label-line-height(t) + // A single point has nothing to spread: it takes the middle, like a lone slot + let slots = ax.mode == "slots" or n == 1 + let pitch-for(inset) = if slots { len / n } else if n > 1 { (len - 2 * inset) / (n - 1) } else { len } + // Flat end labels are centred on the end marks, so the marks move in by half their width + let flat-inset = if slots { 0pt } else { + calc.min(calc.max(ax.inset, calc.max(natural.first(), natural.last()) / 2 + 1pt), len * 0.3) + } + let gap = 0.6 * size0 + // Whether every s-th label, set flat, stays clear of its neighbour + let clear(s) = range(0, n, step: s).windows(2).all(ij => { + let (a, b) = ij + (natural.at(a) + natural.at(b)) / 2 + gap <= (b - a) * pitch-for(flat-inset) + }) + let flat = clear(1) + let thin-skip = if flat { 1 } else { range(2, n + 1).find(clear) } + + // Tilted labels end at their mark and reach left, not right: only the marker needs room + let tilt-inset = if slots { 0pt } else { calc.min(ax.inset, len * 0.3) } + let tilt-pitch = pitch-for(tilt-inset) + // Fraction of the full size at which tilted labels, `k` line heights apart, fit the pitch + let floor = calc.min(t.at("category-label-min-size", default: 6pt), size0) + let fit-at(k) = calc.min(1, tilt-pitch / (k * lh0)) + let (k45, k90) = (1.6, 1.15) + // Of the layouts that hold every label, the one with the largest type wins; at (nearly) + // equal size the least rotation does. Only when none fits at the floor are labels thinned. + let (s45, s90) = (size0 * fit-at(k45), size0 * fit-at(k90)) + let (angle, size, skip) = if flat { (0, size0, 1) } + else if ax.overflow == "thin" { (0, size0, thin-skip) } + else if s90 < floor { (90, floor, calc.ceil(k90 * lh0 * (floor / size0) / tilt-pitch)) } + else if s45 >= floor and s45 >= s90 - 0.05pt { (45, s45, 1) } + else { (90, s90, 1) } + let rotate = angle != 0 + let inset = if rotate { tilt-inset } else { flat-inset } + let pitch = pitch-for(inset) + let lh = lh0 * (size / size0) + let width-at(l) = measure(label-text(l, t, size: size)).width + let wcap = if rotate and max-block != none { + if angle == 45 { calc.max(max-block / 0.7071 - lh, 3 * lh) } else { calc.max(max-block, 3 * lh) } + } + let texts = if wcap == none { ax.labels } else { + ax.labels.map(l => _clip-label(l, c => width-at(c) <= wcap)) + } + let ws = if size == size0 and wcap == none { natural } else { texts.map(width-at) } + // Thinned upright labels are spread evenly with both ends kept; flat ones keep the pairs that were measured + let shown = if skip == 1 { range(n) } + else if angle == 90 { + let m = calc.floor((n - 1) / skip) + 1 + if m >= 2 { range(m).map(j => int(calc.round((n - 1) * j / (m - 1)))) } else { (0,) } + } else { range(0, n, step: skip) } + let wshown = shown.map(i => ws.at(i)).fold(0pt, calc.max) + let first-x = if slots { pitch / 2 } else { inset } + ( + ..ax, n: n, ws: ws, texts: texts, pitch: pitch, inset: inset, rotate: rotate, angle: angle, size: size, + skip: skip, shown: shown, + dlo: if slots { -0.5 } else { 0 }, dhi: if slots { n - 0.5 } else { calc.max(n - 1, 1) }, + block: if angle == 45 { 0.7071 * (wshown + lh) } else if angle == 90 { wshown + 0.2 * lh } else { label-row-height(t) }, + over-left: if angle == 45 { calc.max(0pt, 0.7071 * ws.first() + 0.354 * lh - first-x) } + else if angle == 90 { calc.max(0pt, lh / 2 - first-x) } else { 0pt }, + ) +} + +// ── Scales ────────────────────────────────────────────────────────────────── + +/// Linear scale of a resolved axis laid over `len` starting at `start`. +/// Returns `(pos, dom)`: `pos(v)` maps a value (or category index) to a length +/// along the axis; `dom` is the `(lo, hi)` the full length spans, for overlays +/// that work in data coordinates. With `flip`, larger values sit nearer `start`'s +/// far end (vertical value axes, where values grow upward). +#let axis-scale(ax, start, len, flip: false) = { + let inner = len - 2 * ax.inset + let span = if ax.dhi == ax.dlo { 1 } else { ax.dhi - ax.dlo } + let pos = v => if flip { + start + len - ax.inset - (v - ax.dlo) / span * inner + } else { + start + ax.inset + (v - ax.dlo) / span * inner + } + let e = ax.inset / inner * span + (pos: pos, dom: (lo: ax.dlo - e, hi: ax.dhi + e)) +} + +/// Largest font size, at most `size`, at which every label in `strs` fits a slot +/// `slot` wide with a little air between neighbours; `none` when they would need +/// to go below `min-size`. Direct labels (value labels above bars) use this to +/// shrink, then drop, instead of overlapping. +#let fit-label-size(strs, slot, size, min-size: 5.5pt) = { + let w = strs.fold(0pt, (m, s) => calc.max(m, measure(text(size: size)[#s]).width)) + if w <= 0pt { return size } + let fitted = calc.min(size, size * ((slot * 0.94) / w)) + // A theme whose labels are small already keeps them down to 80% of that size + if fitted >= calc.min(min-size, size * 0.8) { fitted } else { none } +} + +/// Font size, at most `size`, for labels in rows `pitch` apart so neighbouring +/// rows never touch; `none` when that would go below `min-size`. +#let fit-row-size(pitch, size, min-size: 5.5pt) = { + let fitted = calc.min(size, pitch * 0.9) + if fitted >= calc.min(min-size, size * 0.8) { fitted } else { none } +} + +/// Compact text for a data value shown beside a mark: values from 10 000 up +/// are abbreviated the way the axis ticks are (`15k`, `1.5M`); smaller values +/// are shown as given. +#let value-text(v, t) = { + if (type(v) == int or type(v) == float) and calc.abs(v) >= 10000 and t.number-format == "auto" { + format-number(v, digits: 1, mode: "auto") + } else { v } +} +/// A data value in as few characters as `sig` significant digits allow, for labels +/// that are short of room: `12.3456` becomes `12.3` (3 digits) or `12` (2), `85200` +/// becomes `85.2k` or `85k`, `0.012345` becomes `0.0123` or `0.012`. Whole numbers +/// below 10 000 are kept whole, whatever `sig` says. Values that are not numbers +/// are returned as they are. +/// +/// - v (int, float, any): The value +/// - t (dictionary): Resolved theme +/// - sig (int): Significant digits to keep +/// -> str, any +#let compact-text(v, t, sig: 3) = { + if type(v) != int and type(v) != float { return v } + let a = calc.abs(v) + if a == 0 { return "0" } + let (scale, suffix) = if t.number-format != "auto" and t.number-format != "si" { (1, "") } + else if a >= 1000000000 { (1000000000, "B") } + else if a >= 1000000 { (1000000, "M") } + else if a >= 10000 { (1000, "k") } + else { (1, "") } + let x = v / scale + let digits = calc.max(0, sig - 1 - int(calc.floor(calc.log(calc.abs(x), base: 10) + 1e-9))) + str(calc.round(x, digits: digits)) + suffix +} + +// ── For charts that lay out their own rows ────────────────────────────────── +// +// The row-oriented charts (dumbbell, diverging, gantt, bullet, ...) draw their +// own axes. These three helpers keep their signatures; cartesian charts use +// `cartesian-frame` instead. + +// Draw a single right-aligned y-axis category label in the left margin. +#let draw-y-label(label, y, margin-width, theme) = { + place(left + top, dx: 0pt, dy: y, box(width: margin-width - theme.axis-label-gap, height: 0pt, align(right + horizon, + text(size: theme.axis-label-size, fill: theme.text-color)[#label], + ))) +} + +// Height of a row of x tick labels; rotated (45 degrees) labels take their +// widest label's diagonal. +#let measure-x-tick-height(values, theme, rotated: false) = { + if not rotated { return measure(text(size: theme.axis-label-size)[0]).height } + values.fold(0pt, (m, v) => calc.max(m, measure(text(size: theme.axis-label-size)[#v]).width * calc.sqrt(2) / 2)) +} + +// Axis titles: the x title below the tick labels (centred on `x-center`), the y +// title rotated left of the tick labels (centred on `y-center`). `origin-x` and +// `origin-y` are the axis corner; `y-tick-width` and `x-tick-height` the measured +// tick label extents. +#let draw-axis-titles( + x-label, y-label, x-center, y-center, theme, + origin-x: none, origin-y: none, y-tick-width: 0pt, x-tick-height: 0pt, +) = { + let gap = theme.axis-label-gap + let title-gap = theme.at("axis-title-gap", default: gap) + let title(body) = text(size: theme.axis-title-size, fill: theme.text-color)[#body] + if x-label != none { + let lbl = title(x-label) + let tick-h = if x-tick-height != 0pt { x-tick-height } else { measure(text(size: theme.axis-label-size)[0]).height } + let ox-y = if origin-y != none { origin-y } else { y-center * 2 } + place(left + top, dx: x-center - measure(lbl).width / 2, dy: ox-y + gap + tick-h + title-gap, lbl) + } + if y-label != none { + // The rotated box is placed by its centre: measure() of a rotated box is the unrotated size + let lbl = title(y-label) + let (tw, th) = (measure(lbl).width, measure(lbl).height) + let ox-x = if origin-x != none { origin-x } else { theme.axis-padding-left } + let strip-x = ox-x - gap / 2 - y-tick-width - title-gap - th + place(left + top, dx: strip-x + th / 2 - tw / 2, dy: y-center - th / 2, rotate(-90deg, lbl)) + } +} diff --git a/packages/preview/primaviz/0.12.0/src/primitives/container.typ b/packages/preview/primaviz/0.12.0/src/primitives/container.typ new file mode 100644 index 0000000000..d52fa465b8 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/primitives/container.typ @@ -0,0 +1,94 @@ +// container.typ - Chart container wrapper + +#import "../theme.typ": * +#import "./title.typ": draw-title + +/// Fallback inset for non-theme contexts (e.g. heatmap overhead calc). +/// Charts should use theme.container-inset instead. +#let container-inset = 8pt + +/// Space between a chart and a legend beside it. +#let legend-gap = 10pt + +// Wraps a chart body in a box with optional background/border, a title and +// subtitle, and a legend (above, below or beside the body, per +// `theme.legend-position`). +// +// The container is as tall as what it holds: title block, legend and a +// `height`-tall body, laid out and measured by Typst itself, so nothing has to be +// reserved for them and nothing can overflow or leave a dead band. A legend +// beside the body adds its width (`legend-width`: `auto`, the default, is the +// legend's own width); when it is taller than the body the container grows to it. +// Light and dark themes render at the same outer size: the border takes the same +// inset in both. +// +// - width, height: size of the body (the chart without title, legend or inset) +// - legend: legend content, or `none` +// - legend-width: width of a legend beside the body: a length, or `auto` to +// measure the legend +// - extra-legend-separation: extra space between legend and body +#let chart-container(width, height, title, theme, legend: none, legend-width: auto, extra-legend-separation: 0pt, subtitle: none, radius: 0pt, body) = { + let pad = theme.at("container-inset", default: container-inset) + let lp = theme.legend-position + let side-legend = (lp == "right" or lp == "left") and legend != none + let gap = if side-legend { legend-gap } else { 0pt } + let build(legend-width) = box( + width: if side-legend { width + gap + legend-width + 2 * pad } else { width + 2 * pad }, + fill: theme.background, + stroke: theme.border, + inset: pad, + radius: radius, + )[ + // Charts are sized by the theme, not the document: pin the ambient text + // size (Typst's default) and the flow spacing that follows from it, so the + // gaps between title, legend and body and the leading of a wrapped legend + // don't shift with the document's `set text(size: ..)`. + #set text(size: 11pt) + #set par(leading: 0.65em, spacing: 1.2em) + #draw-title(title, theme, subtitle: subtitle) + #if lp == "top" and legend != none { + legend + v(extra-legend-separation) + } + #if lp == "right" and legend != none { + context { + let legend-h = measure(width: legend-width, legend).height + // Center legend relative to chart; allow negative dy when legend is taller + let legend-dy = (height - legend-h) / 2 + let content-h = calc.max(height, legend-h) + // Shift both chart and legend down if legend extends above chart + let base-dy = if legend-dy < 0pt { -legend-dy } else { 0pt } + box(width: width + legend-gap + legend-width, height: content-h)[ + #place(left + top, dy: base-dy, box(width: width, height: height, body)) + #place(left + top, dx: width + legend-gap + extra-legend-separation, dy: base-dy + legend-dy, + box(width: legend-width, legend)) + ] + } + } else if lp == "left" and legend != none { + context { + let legend-h = measure(width: legend-width, legend).height + let legend-dy = (height - legend-h) / 2 + let content-h = calc.max(height, legend-h) + let base-dy = if legend-dy < 0pt { -legend-dy } else { 0pt } + box(width: width + legend-gap + legend-width, height: content-h,)[ + #place(left + top, dy: base-dy + legend-dy, dx: -extra-legend-separation, + box(width: legend-width, legend)) + #place(left + top, dx: legend-width + legend-gap, dy: base-dy, + box(width: width, height: height, body)) + ] + } + } else { + body + if legend != none and lp == "bottom" { + v(extra-legend-separation) + legend + } + } + ] + // A legend beside the body, as wide as its content + if side-legend and legend-width == auto { + context build(measure({ set text(size: 11pt); set par(leading: 0.65em, spacing: 1.2em); legend }).width) + } else { + build(legend-width) + } +} diff --git a/packages/preview/primaviz/0.12.0/src/primitives/fonts.typ b/packages/preview/primaviz/0.12.0/src/primitives/fonts.typ new file mode 100644 index 0000000000..d564d0f052 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/primitives/fonts.typ @@ -0,0 +1,398 @@ +// fonts.typ - Font fitting, label placement, and deconfliction utilities + +/// Checks whether a text label fits inside a given rectangular area. +/// +/// Uses a heuristic based on character width ratio to estimate text width. +/// +/// - available-width (length): Width of the container +/// - available-height (length): Height of the container +/// - text-size (length): Font size of the label +/// - text-len (int): Number of characters in the label +/// -> bool +#let label-fits-inside(available-width, available-height, text-size, text-len) = { + let estimated-width = text-size * 0.6 * text-len + let estimated-height = text-size * 1.2 + estimated-width <= available-width and estimated-height <= available-height +} + +/// Largest font size, at or below `base-size`, whose label fits `available-width`. +/// +/// Inverts the width heuristic `label-fits-inside` uses, so a label sized by +/// this function is one `label-fits-inside` accepts. Results are clamped to +/// `min-size` — a label that cannot fit stays readable rather than fitting. +/// +/// - available-width (length): Width the label has to fit into +/// - base-size (length): Preferred/maximum font size +/// - text-len (int): Number of characters in the label +/// - min-size (length): Floor font size +/// -> length +#let font-to-fit-width(available-width, base-size, text-len, min-size: 4pt) = { + if text-len <= 0 { return base-size } + calc.max(min-size, calc.min(base-size, available-width / (0.6 * text-len))) +} + +/// Returns a skip factor N so that showing every Nth label maintains min-spacing. +/// +/// - count (int): Total number of labels +/// - available-length (length): Total available length for all labels +/// - min-spacing (length): Minimum spacing between label centers +/// -> int +#let density-skip(count, available-length, min-spacing: 12pt) = { + if count <= 0 { return 1 } + let per-label = available-length / count + if per-label >= min-spacing { 1 } else { calc.ceil(min-spacing / per-label) } +} + +/// Computes a font size that fits within the available space. +/// +/// Returns `min(base-size, max(min-size, available * ratio))`. +/// +/// - available (length): Available space (width or height) +/// - base-size (length): Preferred/maximum font size +/// - min-size (length): Floor font size +/// - ratio (float): Fraction of available space to use +/// -> length +#let font-for-space(available, base-size, min-size: 4pt, ratio: 0.6) = { + calc.min(base-size, calc.max(min-size, available * ratio)) +} + +/// Tries shrinking a label font from `base-size` down to `shrink-min` until it fits. +/// +/// Returns `(fits: bool, size: length)`. +/// +/// - available-w (length): Width of the container +/// - available-h (length): Height of the container +/// - base-size (length): Starting (preferred) font size +/// - text-len (int): Number of characters in the label +/// - shrink-min (length): Minimum font size to try before giving up +/// -> dictionary +#let try-fit-label(available-w, available-h, base-size, text-len, shrink-min: 5pt) = { + let steps = calc.max(1, int((base-size - shrink-min) / 1pt) + 1) + let result = (fits: false, size: base-size) + for i in range(steps) { + let sz = base-size - i * 1pt + if sz < shrink-min { break } + if label-fits-inside(available-w, available-h, sz, text-len) { + result = (fits: true, size: sz) + break + } + } + result +} + +/// Places a label near a Cartesian point with quadrant-aware alignment. +/// +/// Clamps the label position to stay within the given bounds rectangle. +/// Optionally draws a leader line from the original point to the displaced label. +/// +/// - x (length): Original x position +/// - y (length): Original y position +/// - body (content): Label content to place +/// - bounds (dictionary): Dict with `left`, `right`, `top`, `bottom` keys +/// - box-width (length): Width of the label box +/// - box-height (length): Height of the label box +/// - offset-y (length): Vertical offset from the point (negative = above) +/// - leader (bool): Whether to draw a leader line +/// - leader-stroke (stroke): Stroke style for the leader line +/// -> content +#let place-cartesian-label(x, y, body, bounds, box-width: 40pt, box-height: 12pt, offset-y: -12pt, leader: false, leader-stroke: 0.5pt + gray) = { + // Determine quadrant-aware offset: push label away from center + let cx = (bounds.left + bounds.right) / 2 + let cy = (bounds.top + bounds.bottom) / 2 + + let offset-x = if x >= cx { 8pt } else { -8pt - box-width } + + // Compute displaced position + let lx = x + offset-x + let ly = y + offset-y + + // Clamp to bounds + let lx = calc.max(bounds.left, calc.min(bounds.right - box-width, lx)) + let ly = calc.max(bounds.top, calc.min(bounds.bottom - box-height, ly)) + + // Leader line from original point to label anchor + if leader { + place(left + top, + line(start: (x, y), end: (lx + box-width / 2, ly + box-height / 2), stroke: leader-stroke)) + } + + // Label + place(left + top, dx: lx, dy: ly, + box(width: box-width, align(center, body))) +} + +/// Pins a label to its placement point by vertical alignment, independent of +/// the surrounding document's font size. +/// +/// `place(dy: y, pin(body))` centres `body` on `y`; with `align: bottom` it +/// sits on `y`, with `align: top` it hangs from it. Prefer this over +/// `move(dy: -0.5em, ...)`: an `em` there resolves against the document's +/// text size, not the label's, so labels drift when the document font changes. +/// +/// - body (content): Label content +/// - width (auto, length): Box width (for horizontal alignment) +/// - align (alignment): Alignment within the zero-height box +/// -> content +#let pin(body, width: auto, align: horizon) = box(width: width, height: 0pt, std.align(align, body)) + +// --------------------------------------------------------------------------- +// Measured label helpers +// +// The functions above estimate text width from a character count (0.6 em per +// character). That is fine for coarse decisions but wrong for the cases that +// matter: bold text, wide capitals, CJK, math. The helpers below measure the +// real glyph run, so a label that "fits" does fit. They call `measure`, so +// they must run inside `context` (every chart body already does). +// --------------------------------------------------------------------------- + +/// Measured width of `body` set at `size`. +/// +/// - body (content, str): Label +/// - size (length): Font size +/// - weight (str, int): Font weight +/// -> length +#let text-width(body, size, weight: "regular") = measure(text(size: size, weight: weight)[#body]).width + +/// Measured height of `body` set at `size` (one line, cap-height to baseline +/// plus any descender the content carries). +/// +/// - body (content, str): Label +/// - size (length): Font size +/// - weight (str, int): Font weight +/// -> length +#let text-height(body, size, weight: "regular") = measure(text(size: size, weight: weight)[#body]).height + +/// Widest of several labels, measured. +/// +/// - labels (array): Labels +/// - size (length): Font size +/// - weight (str, int): Font weight +/// -> length +#let max-text-width(labels, size, weight: "regular") = { + labels.fold(0pt, (acc, l) => calc.max(acc, text-width(l, size, weight: weight))) +} + +/// Largest font size, between `min` and `base`, at which `body` fits in +/// `avail` (measured, not estimated). Glyph advances scale with the size, so +/// one measurement at `base` is enough. When even `min` is too big the result +/// is `min`: the caller decides whether to wrap, thin out or drop the label. +/// +/// - body (content, str): Label +/// - avail (length): Width the label has to fit into +/// - base (length): Preferred size +/// - min (length): Floor size +/// - weight (str, int): Font weight +/// -> length +#let fit-font-size(body, avail, base, min: 5pt, weight: "regular") = { + let w = text-width(body, base, weight: weight) + if w <= avail or w <= 0pt { base } else { calc.max(min, base * (avail / w)) } +} + +/// Every Nth label is enough when labels of width `label-w` are spaced +/// `pitch` apart: returns that N (at least 1). This is the first resort when +/// labels collide: thin them out, do not rotate them. +/// +/// - label-w (length): Widest label +/// - pitch (length): Distance between neighbouring label centres +/// - gap (length): Clear space wanted between neighbouring labels +/// -> int +#let label-stride(label-w, pitch, gap: 4pt) = { + if pitch <= 0pt { return 1 } + calc.max(1, int(calc.ceil((label-w + gap) / pitch - 1e-9))) +} + +/// `c` of `n` positions, spread evenly over the whole run, always including +/// the first and the last (when `c` is 1, only the first). Neighbours are at +/// least `floor((n - 1) / (c - 1))` positions apart. +/// +/// - n (int): Number of positions +/// - c (int): How many to keep +/// -> array of ints, ascending +#let spread-indices(n, c) = { + if n <= 0 or c <= 0 { return () } + if c >= n { return array.range(n) } + if c == 1 { return (0,) } + array.range(c).map(j => int(calc.round(j * (n - 1) / (c - 1)))) +} + +/// Which of `n` evenly spaced positions to label when labels have to be +/// at least `k` positions apart. Unlike "every k-th from the first", the +/// labelled positions are spread evenly over the whole run and always include +/// the first and the last (never closer than `k` to each other); when not even +/// two fit, only the first is kept. +/// +/// - n (int): Number of positions +/// - k (int): Minimum distance, in positions, between labelled ones +/// -> array of ints, ascending +#let thin-indices(n, k) = { + if n <= 0 { return () } + if k <= 1 or n == 1 { return array.range(n) } + spread-indices(n, calc.quo(n - 1, k) + 1) +} + +/// Label size for positions `pitch` apart (rows, columns): the largest size +/// up to `base` at which neighbouring labels do not touch (`line-k` is the +/// line height the label needs, in em; 1.0 leaves room for descenders). The +/// size shrinks to `floor` before anything is dropped; only if even `floor` +/// does not fit does `stride` rise above 1, to the number of positions +/// between labels (feed it to `thin-indices`). +/// +/// - pitch (length): Distance between neighbouring label centres +/// - base (length): Preferred size +/// - floor (length): Smallest size worth printing +/// - line-k (float): Line height needed, in em +/// -> dictionary with `size` and `stride` +#let fit-pitch(pitch, base, floor: 5pt, line-k: 1.0) = { + let s = calc.min(base, pitch / line-k) + if s >= floor { (size: s, stride: 1) } else { + (size: floor, stride: calc.max(2, int(calc.ceil(floor * line-k / pitch - 1e-9)))) + } +} + +/// Lays labels out along one axis (a column of names, a row of them, the ends +/// of lines, an arc read as a line) so that none overlaps another. The one +/// spreader every chart uses. +/// +/// Each label is wanted at `wants.at(i)` (its centre). The result moves the +/// labels as little as it can (least squares), never reorders them (equal +/// wishes keep their input order) and keeps neighbours `gap` apart, clear +/// space between their extents. Labels that collide form a cluster that is +/// centred on where its members wanted to be, then clamped into `lo..hi`. +/// A solution inside `lo..hi` is found whenever one exists. +/// +/// When the labels cannot all fit, in this order: the `gap` is squeezed (down +/// to `min-gap`, 0 by default: extents touch); then labels are left out, +/// those of the lowest `priority` first, and among equals the one whose +/// removal leaves the smallest hole, the first and last last, so the rest +/// are thinned evenly. The labels that remain never overlap. A label left +/// out has `none` for its place: the caller does not draw it. +/// +/// All positions are lengths along the axis (an angle can be passed as +/// points, one point per degree). `lo` and `hi` bound the labels' extents +/// (their edges), which are their centres when `sizes` is 0. +/// +/// - wants (array): Wanted centre of each label, in any order +/// - sizes (length, array): Extent of the label along the axis; one length for all or one each +/// - gap (length): Clear space wanted between neighbours +/// - min-gap (length): Least clear space accepted before labels are left out; negative +/// when `sizes` carry air around the text, which crowded labels may then use +/// - lo (none, length): Start of the room; none: unbounded +/// - hi (none, length): End of the room; none: unbounded +/// - priority (none, array): Higher survives when labels must be left out +/// - need (none, function): `(a, b) => length`, the least distance between the centres of +/// neighbours `a` then `b` (indices into `wants`); replaces `sizes` and `gap` for spacing +/// -> array, in the order of `wants`, of lengths (centres) or `none` (left out) +#let spread-labels(wants, sizes: 0pt, gap: 0pt, min-gap: 0pt, lo: none, hi: none, priority: none, need: none) = { + let n = wants.len() + if n == 0 { return () } + let size-of(i) = if type(sizes) == array { sizes.at(i) } else { sizes } + let dist(g, a, b) = if need != none { need(a, b) } else { (size-of(a) + size-of(b)) / 2 + g } + // What `ls` take up with no space between neighbours (and the space each gap adds) + let base(ls) = { + let t = (size-of(ls.first()) + size-of(ls.last())) / 2 + for k in range(1, ls.len()) { t += dist(0pt, ls.at(k - 1), ls.at(k)) } + t + } + let live = range(n).sorted(key: i => (wants.at(i), i)) + let g = gap + if lo != none and hi != none { + let room = hi - lo + 0.01pt + while live.len() > 1 and base(live) + (live.len() - 1) * min-gap > room { + let m = live.len() + let key(j) = { + let end = j == 0 or j == m - 1 + (if priority == none { 0 } else { priority.at(live.at(j)) }, if end { 1 } else { 0 }, + if end { 0pt } else { wants.at(live.at(j + 1)) - wants.at(live.at(j - 1)) }, -live.at(j)) + } + let _ = live.remove(range(m).sorted(key: key).first()) + } + if live.len() > 1 and need == none { + g = calc.max(min-gap, calc.min(gap, (room - base(live)) / (live.len() - 1))) + } + } + // Labels stacked as tightly as allowed, `offs` from the first: the nearest layout is the + // nearest non-decreasing fit (pool adjacent violators) to the wishes less those offsets + let offs = (0pt,) + for k in range(1, live.len()) { offs.push(offs.last() + dist(g, live.at(k - 1), live.at(k))) } + let blocks = () + for (k, i) in live.enumerate() { + blocks.push((wants.at(i) - offs.at(k), 1)) + while blocks.len() > 1 { + let (a, b) = (blocks.at(-2), blocks.at(-1)) + if a.at(0) / a.at(1) <= b.at(0) / b.at(1) { break } + let _ = blocks.pop() + blocks.at(-1) = (a.at(0) + b.at(0), a.at(1) + b.at(1)) + } + } + let fit = () + for (sum, count) in blocks { fit += (sum / count,) * count } + // Clamp into the room (the first label's start, the last one's end) + let lb = if lo == none { none } else { lo + size-of(live.first()) / 2 } + let ub = if hi == none { none } else { hi - size-of(live.last()) / 2 - offs.last() } + let out = (none,) * n + for (k, i) in live.enumerate() { + let z = fit.at(k) + z = if lb != none and ub != none and lb > ub { (lb + ub) / 2 } else { + if lb != none { z = calc.max(z, lb) } + if ub != none { z = calc.min(z, ub) } + z + } + // A label nothing moved keeps its wish exactly + out.at(i) = if z == wants.at(i) - offs.at(k) { wants.at(i) } else { z + offs.at(k) } + } + out +} + +/// Places `body` with its vertical centre on `y` and its left edge on `x`, +/// whatever its height: the body is measured, so a wrapped or stacked +/// multi-line label is centred as a block. (A zero-height `box` aligned to +/// the horizon only works for one line; a stack inside one collapses.) +/// +/// - x (length): Left edge of the label box +/// - y (length): Vertical centre +/// - body (content): Label +/// - width (auto, length): Box width; text wraps inside it +/// - align (alignment): Horizontal alignment inside the box +/// -> content +#let place-centered(x, y, body, width: auto, align: left) = { + let b = box(width: width, std.align(align, body)) + let h = measure(b).height + place(left + top, dx: x, dy: y - h / 2, b) +} + +/// Decides how to set a label, with an optional second (detail) line, inside +/// a `w` × `h` cell: the largest size, from `base` down to `min`, at which the +/// label fits *measured*: on one line, or wrapped at spaces when no word is +/// wider than the cell. The detail line is added when it fits under the label. +/// +/// Returns `none` when not even `min` fits: the caller then leaves the cell +/// unlabelled (or labels it elsewhere) instead of printing past its edges. +/// +/// - label (content, str): Main label +/// - detail (none, content, str): Secondary line (a value, a share) +/// - w (length): Cell width available to text +/// - h (length): Cell height available to text +/// - base (length): Preferred size +/// - min (length): Smallest readable size +/// - weight (str, int): Weight of the main label +/// - detail-k (float): Detail size relative to the label +/// - lead-k (float): Space between the lines, in em of the label +/// -> none, dictionary with `size`, `wrapped` and `detail` +#let fit-cell-label(label, detail, w, h, base, min: 6pt, weight: "bold", detail-k: 0.85, lead-k: 0.25) = { + if w <= 0pt or h <= 0pt { return none } + let words = if type(label) == str { label.split(" ") } else { (label,) } + let steps = int((base - min) / 0.5pt) + for k in range(calc.max(steps, 0) + 1) { + let s = base - k * 0.5pt + if s * 1.2 > h { continue } + let lw = text-width(label, s, weight: weight) + let main-h = if lw <= w { s * 1.2 } else if max-text-width(words, s, weight: weight) <= w { + measure(box(width: w, text(size: s, weight: weight)[#label])).height + } else { none } + if main-h == none or main-h > h { continue } + let det-h = s * (detail-k * 1.2 + lead-k) + let has-detail = detail != none and detail != "" and main-h + det-h <= h and text-width(detail, s * detail-k) <= w + return (size: s, wrapped: lw > w, detail: has-detail) + } + none +} diff --git a/packages/preview/primaviz/0.12.0/src/primitives/frame.typ b/packages/preview/primaviz/0.12.0/src/primitives/frame.typ new file mode 100644 index 0000000000..bef12dc094 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/primitives/frame.typ @@ -0,0 +1,250 @@ +// frame.typ - The frame every cartesian chart is drawn in +// +// A chart describes its axes (`value-axis`, `category-axis`) and a `body` that +// draws the marks; the frame does the rest, measuring as it goes: +// +// container -> title -> [ y title | y tick labels | PLOT | y2 ... ] +// [ x tick labels ] +// [ x title ] -> legend +// +// Margins come from the text that has to fit in them (tick label widths, the +// rotated title's thickness, the label row's height), never from fixed padding, +// so tick labels, axis titles, legends and container edges keep the same +// measured offsets in every chart and theme. The grid, tick marks, tick labels +// and the scale all read one resolved tick list. + +#import "../theme.typ": * +#import "container.typ": chart-container, legend-gap +#import "annotations.typ": draw-annotations, annotation-reserve +#import "axes.typ": * +#import "fonts.typ": pin + +/// Draws a cartesian chart: container, title, legend, axes, grid, tick labels, +/// axis titles and annotations around the marks `body` draws. +/// +/// `body` is called with the plot record `p`: +/// - `p.x0`, `p.y0`, `p.x1`, `p.y1`, `p.w`, `p.h`: the plot rectangle, in the +/// coordinates of the `width` x `height` box `body` draws into +/// - `p.px(v)`, `p.py(v)`, `p.py2(v)`: data value (category index on a +/// category axis) to position; `p.t` is the theme +/// - `p.X`, `p.Y`, `p.Y2`: the resolved axes (`nt` tick record, `pitch`, ...) +/// Marks are placed with `place(left + top, dx: .., dy: .., ..)`. +/// +/// - width (length): Width of the chart box (axes and titles included) +/// - height (length): Height of the chart box +/// - t (dictionary): Resolved theme +/// - x (dictionary): x axis (`value-axis` or `category-axis`) +/// - y (dictionary): y axis (`value-axis`; horizontal bars use the row primitives, `row-chart`) +/// - y2 (none, dictionary): Optional second value axis on the right, aligned to +/// the grid of `y` when the two can share one +/// - title (none, content): Chart title +/// - subtitle (none, content): Chart subtitle +/// - radius (length): Container corner radius +/// - legend (none, content): Legend, placed per `theme.legend-position` +/// - extra-legend-separation (length): Extra space between legend and chart +/// - show-ticks (bool): Draw tick marks on the axis lines +/// - show-minor-grid (bool): Draw lighter grid lines between the major ones +/// - axis-lines (dictionary): `(x: bool, y: bool)`: which axis lines to draw +/// - baseline (dictionary): `(y: v)` or `(x: v)`: draw the axis line at +/// that value instead of at the plot edge (bars: zero) +/// - reserve (dictionary): Extra room `left`/`right`/`top`/`bottom` inside the +/// box for marks that stick out of the plot (value labels, end labels) +/// - annotations (none, array): Annotation descriptors, drawn over the marks +/// - body (function): `p => content` +/// -> content +#let cartesian-frame( + width, height, t, + x: none, y: none, y2: none, + title: none, subtitle: none, radius: 0pt, + legend: none, extra-legend-separation: 0pt, + show-ticks: false, show-minor-grid: false, + axis-lines: (x: true, y: true), + baseline: (:), + reserve: (:), + annotations: none, + body, +) = { + // A legend beside the chart takes its room out of `width`, so the chart is as + // wide as asked (plus the container's padding) wherever the legend goes + let side-legend = legend != none and (t.legend-position == "right" or t.legend-position == "left") + let legend-w = if side-legend { calc.min(measure(legend).width, 0.4 * width) } else { 0pt } + let width = if side-legend { width - legend-w - legend-gap } else { width } + + let ts = t.axis-title-size + // Gap between an axis and its tick labels, and between tick labels and the title + let g = t.label-offset + if show-ticks { t.tick-length * 0.6 } else { 0pt } + let tg = t.axis-title-gap + let rsv(key) = reserve.at(key, default: 0pt) + let ann = annotation-reserve(annotations, t) + let pad-top = calc.max(t.axis-padding-top, rsv("top"), ann.top) + + let title-of(ax) = if ax == none or ax.title == none { none } else { + text( + size: ts, fill: if ax.color == auto { t.text-color } else { ax.color }, + top-edge: "ascender", bottom-edge: "descender", + )[#ax.title] + } + let thick(c) = if c == none { 0pt } else { measure(c).height } + let label-w(ax) = if ax == none or not ax.labelled { 0pt } else { widest-label(ax.labels, t) } + let text-of(ax, i) = ax.at("texts", default: ax.labels).at(i) + + // ── y axes: their tick labels and titles set the side margins ────────────── + let x-title = title-of(x) + let x-title-h = if x-title == none { 0pt } else { tg + thick(x-title) } + // Room for the y tick budget before the row of x labels is known + let est = calc.max(height - pad-top - g - label-row-height(t) - x-title-h, 40pt) + let (ya, y2a) = if y.kind == "value" and y2 != none { + resolve-value-pair(y, y2, est, t) + } else { + (resolve-value-axis(y, est, true, t), + if y2 == none { none } else { resolve-value-axis(y2, est, true, t) }) + } + let y-title = title-of(ya) + let y2-title = title-of(y2a) + let y-w = label-w(ya) + let y2-w = label-w(y2a) + let side(w, ttl) = (if w > 0pt { w + g } else { 0pt }) + (if ttl == none { 0pt } else { thick(ttl) + tg }) + let pad-left0 = side(y-w, y-title) + rsv("left") + let pad-right0 = (if y2a != none { side(y2-w, y2-title) } else { t.label-offset }) + rsv("right") + ann.right + + // ── x axis: resolved over the plot width; end labels may widen the margins ── + let corner = x.kind == "value" and x.inset == 0pt and ya.kind == "value" and ya.inset == 0pt + let layout-x(pl, pr) = { + let w = calc.max(width - pl - pr, 24pt) + let xa = if x.kind == "value" { resolve-value-axis(x, w, false, t) } else { resolve-category-axis(x, w, t, max-block: 0.34 * height) } + let (over-l, over-r) = if xa.kind == "value" { + let ws = xa.labels.map(l => measure(label-text(l, t)).width) + (if corner { 0pt } else { calc.max(0pt, ws.first() / 2 - xa.inset) }, + calc.max(0pt, ws.last() / 2 - xa.inset)) + } else { (xa.over-left, 0pt) } + (xa: xa, w: w, over-l: over-l, over-r: over-r) + } + let first = layout-x(pad-left0, pad-right0) + let pad-left = calc.max(pad-left0, first.over-l) + let pad-right = calc.max(pad-right0, first.over-r) + let lx = if pad-left == pad-left0 and pad-right == pad-right0 { first } else { layout-x(pad-left, pad-right) } + let (xa, plot-w) = (lx.xa, lx.w) + + let pad-bottom = g + xa.block + x-title-h + rsv("bottom") + let plot-h = calc.max(height - pad-top - pad-bottom, 24pt) + + let (x0, y0) = (pad-left, pad-top) + let (x1, y1) = (x0 + plot-w, y0 + plot-h) + let xs = axis-scale(xa, x0, plot-w) + let ys = axis-scale(ya, y0, plot-h, flip: ya.kind == "value") + let y2s = if y2a != none { axis-scale(y2a, y0, plot-h, flip: true) } + let (px, py) = (xs.pos, ys.pos) + + let p = ( + t: t, x0: x0, y0: y0, x1: x1, y1: y1, w: plot-w, h: plot-h, + X: xa, Y: ya, Y2: y2a, px: px, py: py, py2: if y2s != none { y2s.pos }, + dom: (x: xs.dom, y: ys.dom), + ) + + // ── drawing helpers ──────────────────────────────────────────────────────── + let rule(a, b, stroke) = place(left + top, line(start: a, end: b, stroke: stroke)) + let row-h = label-row-height(t) + let axis-color(ax) = if ax.color == auto { t.text-color } else { ax.color } + + // y tick labels: right-aligned against the axis (left) or left-aligned (right) + let y-labels(ax, pos, left-side) = if ax.labelled { + let w = label-w(ax) + for (i, v) in ax.nt.ticks.enumerate() { + place(left + top, dx: if left-side { x0 - g - w } else { x1 + g }, dy: pos(v), + pin(width: w, align: (if left-side { right } else { left }) + horizon, + label-text(text-of(ax, i), t, fill: axis-color(ax)))) + } + } + + // x tick labels: flat and centred, or tilted so the text ends at its mark + let x-labels() = if xa.labelled { + let ticks = if xa.kind == "value" { xa.nt.ticks } else { xa.shown } + for (k, v) in ticks.enumerate() { + let i = if xa.kind == "value" { k } else { v } + let cx = px(v) + let lbl = label-text(text-of(xa, i), t, size: if xa.kind == "category" { xa.size } else { auto }) + let w = measure(lbl).width + if xa.kind == "category" and xa.angle == 90 { + // Upright: reads upward and ends at its mark, centred on it + let lh = label-line-height(t) * (xa.size / t.axis-label-size) + place(left + top, dx: cx - w, dy: y1 + g - lh / 2, + rotate(-90deg, origin: right + horizon, box(width: w, height: lh, lbl))) + } else if xa.kind == "category" and xa.rotate { + let lh = label-line-height(t) * (xa.size / t.axis-label-size) + place(left + top, dx: cx - w, dy: y1 + g - 0.15 * lh, + rotate(-45deg, origin: right + horizon, box(width: w, height: lh, lbl))) + } else { + // At the origin corner the first label starts at the axis so it clears the y labels + let dx = if corner and k == 0 { x0 } else { cx - w / 2 } + place(left + top, dx: dx, dy: y1 + g, box(width: w, height: row-h, align(center + top, lbl))) + } + } + } + + let tick-marks() = if show-ticks { + let tl = t.tick-length + for v in ya.nt.ticks { rule((x0 - tl, py(v)), (x0, py(v)), t.axis-stroke) } + if y2s != none { for v in y2a.nt.ticks { rule((x1, y2s.pos(v)), (x1 + tl, y2s.pos(v)), t.axis-stroke) } } + let xs-ticks = if xa.kind == "value" { xa.nt.ticks } else { xa.shown } + for v in xs-ticks { rule((px(v), y1), (px(v), y1 + tl), t.axis-stroke) } + } + + let grid() = if t.show-grid != false { + let minor = t.at("minor-grid-stroke", default: 0.25pt + t.text-color-light.transparentize(60%)) + // Lines run across the plot for every value axis; category axes have none + let lines(ax, pos, horizontal) = { + let at(c, stroke) = if horizontal { rule((x0, pos(c)), (x1, pos(c)), stroke) } else { rule((pos(c), y0), (pos(c), y1), stroke) } + if show-minor-grid { + for (a, b) in ax.nt.ticks.windows(2) { + for j in range(1, 4) { at(a + (b - a) * j / 4, minor) } + } + } + for v in ax.nt.ticks { at(v, t.grid-stroke) } + } + if ya.kind == "value" { lines(ya, py, true) } + if xa.kind == "value" { lines(xa, px, false) } + } + + let axes() = { + let base-y = baseline.at("y", default: none) + let base-x = baseline.at("x", default: none) + let inside(ax, v) = v != none and ax.kind == "value" and v > ax.dlo and v < ax.dhi + let line-y = if inside(ya, base-y) { py(base-y) } else { y1 } + let line-x = if inside(xa, base-x) { px(base-x) } else { x0 } + if axis-lines.at("x", default: true) { rule((x0, line-y), (x1, line-y), t.axis-stroke) } + if axis-lines.at("y", default: true) { + rule((line-x, y0), (line-x, y1), t.axis-stroke) + if y2a != none { rule((x1, y0), (x1, y1), t.axis-stroke) } + } + } + + let titles() = { + if x-title != none { + place(left + top, dx: x0 + plot-w / 2 - measure(x-title).width / 2, dy: y1 + g + xa.block + tg, x-title) + } + // Rotated titles: the text box is placed so its centre lands on the + // centre of the strip they occupy + let side-title(ttl, strip-x) = if ttl != none { + let (tw, th) = (measure(ttl).width, measure(ttl).height) + place(left + top, dx: strip-x + th / 2 - tw / 2, dy: y0 + plot-h / 2 - th / 2, rotate(-90deg, ttl)) + } + side-title(y-title, x0 - rsv("left") - side(y-w, none) - tg - thick(y-title)) + side-title(y2-title, x1 + g + y2-w + tg) + } + + chart-container( + width, height, title, t, legend: legend, legend-width: legend-w, + extra-legend-separation: extra-legend-separation, subtitle: subtitle, radius: radius, + box(width: width, height: height, { + grid() + body(p) + axes() + tick-marks() + y-labels(ya, py, true) + if y2a != none { y-labels(y2a, y2s.pos, false) } + x-labels() + titles() + draw-annotations(annotations, x0, y0, plot-w, plot-h, xs.dom.lo, xs.dom.hi, ys.dom.lo, ys.dom.hi, t) + }), + ) +} diff --git a/packages/preview/primaviz/0.12.0/src/primitives/layout.typ b/packages/preview/primaviz/0.12.0/src/primitives/layout.typ new file mode 100644 index 0000000000..aa753a1dd5 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/primitives/layout.typ @@ -0,0 +1,11 @@ +// layout.typ - Re-export hub for layout utilities +// +// Functions are implemented in focused modules: +// sizing.typ — resolve-size, page-grid +// fonts.typ — label-fits-inside, density-skip, font-for-space, +// try-fit-label, spread-labels, place-cartesian-label +// +// This file re-exports everything so existing imports continue to work. + +#import "sizing.typ": * +#import "fonts.typ": * diff --git a/packages/preview/primaviz/0.12.0/src/primitives/legend.typ b/packages/preview/primaviz/0.12.0/src/primitives/legend.typ new file mode 100644 index 0000000000..1d45073765 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/primitives/legend.typ @@ -0,0 +1,244 @@ +// legend.typ - Legend rendering primitives + +#import "../theme.typ": * +#import "../util.typ": heat-color +#import "fonts.typ": pin +#import "axes.typ": _clip-label, value-text + +/// Side of a legend swatch: the theme's `legend-swatch-size`, never more than +/// three quarters of the legend text (`legend-size`): a swatch is a key to the label +/// beside it and must not dominate it. So the key is configurable, through a theme +/// override (`theme: (legend-swatch-size: 5pt)`) or per legend by passing a theme +/// with the key set (`(..t, legend-swatch-size: 5pt)`, as `dumbbell-chart` does for +/// its dots), but it only makes swatches smaller than that cap, not larger. +/// +/// - theme (dictionary): Resolved theme +/// -> length +#let legend-swatch-size(theme) = calc.min(theme.legend-swatch-size, theme.legend-size * 0.75) + +// Optional legend title rendered above entries. +#let _legend-title(title, theme) = { + if title != none { + text(size: theme.legend-size, weight: "bold", fill: theme.text-color)[#title] + v(3pt) + } +} + +// One legend entry: a swatch centred on the label's cap height, then the label. +// Swatch and label are one box so an entry never breaks across lines. It is +// built in code mode, with no markup spaces, so its width does not depend on the +// ambient text size (the legend is measured where it is made and drawn elsewhere). +#let _legend-entry(name, color, theme, swatch-type) = { + let sz = legend-swatch-size(theme) + // Shift that centres a swatch of height `h` on the label's cap-height band + let centred(h) = (h - 0.66 * theme.legend-size) / 2 + box({ + if swatch-type == "line" { + box(width: 1.6 * sz, height: theme.stroke-thick, fill: color, baseline: centred(theme.stroke-thick)) + } else if swatch-type == "circle" { + box(width: sz, height: sz, baseline: centred(sz), + circle(radius: sz / 2, fill: color, stroke: theme.marker-stroke)) + } else { + box(width: sz, height: sz, fill: color, baseline: centred(sz), radius: sz * 0.2) + } + h(0.4 * sz) + text(size: theme.legend-size, fill: theme.text-color)[#name] + }) +} + +/// Widest a legend entry may be in a chart `width` wide: the room a side legend +/// is given (40% of the width), or the full width for a legend above or below. +/// Charts pass this as `max-width` to the legend they build. +/// +/// - width (length): Chart width +/// - theme (dictionary): Resolved theme +/// -> length +#let legend-max-width(width, theme) = if theme.legend-position == "right" or theme.legend-position == "left" { + 0.4 * width +} else { width } + +// The name of an entry as drawn: a string name that would make its entry wider +// than `max-width` is cut and closed with an ellipsis (content names are kept). +#let _entry-name(name, color, theme, swatch-type, max-width) = if max-width == none { name } else { + _clip-label(name, c => measure(_legend-entry(c, color, theme, swatch-type)).width <= max-width) +} + +// Name and color of the i-th legend entry (a string or a dict with name/color). +#let _entry-parts(entry, i, theme) = ( + if type(entry) == dictionary and "name" in entry { entry.name } else { entry }, + if type(entry) == dictionary and "color" in entry { entry.color } else { get-color(theme, i) }, +) + +// The legends below are laid out at Typst's default type size and leading, whatever +// the document's `set text(size: ..)`: their entries carry their own sizes, and what +// is left (the leading between wrapped rows) must not move with the ambient size, so +// a legend measures the same wherever it is measured. +#let _pinned(content) = { set text(size: 11pt); set par(leading: 0.65em); content } + +// Horizontal legend (for bar, line, area charts). Entries flow onto further rows +// when they do not fit side by side; with `max-width`, an entry wider than that is cut. +#let draw-legend(entries, theme, swatch-type: "box", title: none, max-width: none) = { + v(5pt) + align(center, _pinned({ + _legend-title(title, theme) + for (i, entry) in entries.enumerate() { + // Gaps go between entries, so the row stays centred + if i > 0 { h(theme.legend-gap) } + let (name, color) = _entry-parts(entry, i, theme) + _legend-entry(_entry-name(name, color, theme, swatch-type, max-width), color, theme, swatch-type) + } + })) +} + +// Vertical legend (for pie, radar, side panels). `width: auto` fits the widest entry; +// with `max-width`, an entry wider than that is cut with an ellipsis. +#let draw-legend-vertical(entries, theme, width: 130pt, title: none, swatch-type: "box", max-width: none) = { + box(width: width, _pinned({ + _legend-title(title, theme) + for (i, entry) in entries.enumerate() { + let (name, color) = _entry-parts(entry, i, theme) + _legend-entry(_entry-name(name, color, theme, swatch-type, max-width), color, theme, swatch-type) + if i < entries.len() - 1 { + linebreak() + v(2pt) + } + } + })) +} + +/// Automatically picks horizontal or vertical legend based on +/// `theme.legend-position`. Returns `none` when the legend is +/// suppressed (`show-legend` is false or position is `"none"`). +/// +/// - entries (array): Legend entries — strings or dicts with `name` (and optional `color`) +/// - theme (dictionary): Resolved theme +/// - show-legend (bool): Master toggle; when false nothing is rendered +/// - swatch-type (str): `"box"`, `"line"`, or `"circle"` +/// - title (none, str): Optional legend title displayed above entries +/// - fit-width (bool): Size a side legend to its widest entry instead of the default column +/// - max-width (none, length): Widest an entry may be (see `legend-max-width`): longer names +/// are cut with an ellipsis, and a horizontal legend wraps onto further rows. `none` leaves +/// entries as long as they are +/// -> content, none +#let draw-legend-auto(entries, theme, show-legend: true, swatch-type: "box", title: none, fit-width: false, max-width: none) = { + if not show-legend { return } + if theme.legend-position == "none" { return } + if entries.len() == 0 { return } + + if theme.legend-position == "right" or theme.legend-position == "left" { + draw-legend-vertical(entries, theme, title: title, swatch-type: swatch-type, max-width: max-width, ..if fit-width { (width: auto) }) + } else { + draw-legend(entries, theme, swatch-type: swatch-type, title: title, max-width: max-width) + } +} + +// The end labels of a gradient legend, their type size and the width the legend +// takes. Numbers print to one decimal, large ones in the compact form of tick labels +// (`1.5B`); a label wider than the bar and its margin shrinks (to 5pt at most), and +// when even that does not fit the legend is as wide as its widest label. +#let _gradient-parts(min-val, max-val, theme, bar-width) = { + let label(v) = if (type(v) == int or type(v) == float) and calc.abs(v) >= 10000 { value-text(v, theme) } + else if type(v) == int or type(v) == float { calc.round(v, digits: 1) } else { v } + let (hi, lo) = (label(max-val), label(min-val)) + let widest(sz) = calc.max(measure(text(size: sz)[#hi]).width, measure(text(size: sz)[#lo]).width) + let room = bar-width + 8pt + let sz = theme.axis-label-size + let w = widest(sz) + let sz = if w <= room { sz } else { calc.max(5pt, sz * (room / w)) } + (hi: hi, lo: lo, size: sz, width: calc.max(room, widest(sz)), + w-hi: measure(text(size: sz)[#hi]).width, w-lo: measure(text(size: sz)[#lo]).width) +} + +/// Width of `draw-gradient-legend`: the bar and its margin, wider when the end labels +/// of a large or long number need it. For charts that reserve room for the legend. +/// +/// - min-val (number): Minimum data value +/// - max-val (number): Maximum data value +/// - theme (dictionary): Resolved theme +/// - bar-width (length): Width of the gradient bar +/// -> length +#let gradient-legend-width(min-val, max-val, theme, bar-width: 15pt) = _gradient-parts(min-val, max-val, theme, bar-width).width + +/// Gradient color bar legend for heatmaps and color-mapped charts. +/// +/// Renders a vertical gradient bar with min/max labels. Can be placed +/// inline or absolutely positioned by the caller. The labels stay inside the +/// legend's width (`gradient-legend-width`): numbers of 10 000 and more are +/// shortened (`1.5B`), and a label wider than the bar shrinks to fit. +/// +/// - min-val (number): Minimum data value (label at bottom) +/// - max-val (number): Maximum data value (label at top) +/// - palette (str, array): Palette name or color stop array +/// - theme (dictionary): Resolved theme +/// - bar-width (length): Width of the gradient bar +/// - bar-height (length): Height of the gradient bar +/// - steps (int): Number of discrete color segments +/// - reverse (bool): Reverse palette direction +/// -> content +#let draw-gradient-legend(min-val, max-val, palette, theme, bar-width: 15pt, bar-height: auto, steps: 30, reverse: false) = { + let h = if bar-height == auto { 80pt } else { bar-height } + let step-h = h / steps + let g = _gradient-parts(min-val, max-val, theme, bar-width) + // A label is centred on the bar, or starts at the legend's edge when it is wider + let at(w) = calc.max(0pt, calc.min(bar-width / 2 - w / 2, g.width - w)) + box(width: g.width, height: h + 4pt)[ + // Gradient bar + #for i in array.range(steps) { + let normalized = 1 - i / steps + let cell-color = heat-color(normalized, palette: palette, reverse: reverse) + place(left + top, + dx: 0pt, + dy: (i / steps) * h, + rect(width: bar-width, height: step-h + 0.5pt, fill: cell-color, stroke: none)) + } + // Max label (above bar) + #place(left + top, dx: at(g.w-hi), dy: -2pt, + pin(width: g.w-hi, align: left + bottom, text(size: g.size, fill: theme.text-color)[#g.hi])) + // Min label (below bar) + #place(left + top, dx: at(g.w-lo), dy: h + 2pt, + box(width: g.w-lo, text(size: g.size, fill: theme.text-color)[#g.lo])) + ] +} + +/// Size legend for bubble charts — shows 2-3 reference circles with labels. +/// +/// - sizes (array): Array of (value, label) pairs for reference bubbles +/// - max-radius (length): Maximum bubble radius (for scaling) +/// - max-value (number): Maximum data value (for scaling) +/// - theme (dictionary): Resolved theme +/// - title (none, str): Optional title (e.g., "Market Value (£M)") +/// -> content +#let draw-size-legend(sizes, max-radius, max-value, theme, title: none, max-legend-radius: 12pt) = { + let cap = calc.min(max-radius, max-legend-radius) + // One reference circle and its label, a single box (code mode: its width does + // not depend on the ambient text size) + let entry(val, lbl) = box({ + let r = calc.max(2pt, cap * calc.sqrt(val / calc.max(1, max-value))) + box(baseline: r - 1pt, box(width: 2 * r, height: 2 * r, + circle(radius: r, fill: none, stroke: theme.text-color-light + 0.75pt))) + h(2pt) + text(size: theme.legend-size, fill: theme.text-color)[#lbl] + }) + if theme.legend-position == "right" or theme.legend-position == "left" { + // Beside the chart: a column, like the series legend + box(_pinned({ + _legend-title(title, theme) + for (i, (val, lbl)) in sizes.enumerate() { + entry(val, lbl) + if i < sizes.len() - 1 { linebreak(); v(2pt) } + } + })) + } else { + v(3pt) + align(center, _pinned({ + if title != none { + text(size: theme.legend-size, fill: theme.text-color)[#title] + h(6pt) + } + for (i, (val, lbl)) in sizes.enumerate() { + if i > 0 { h(8pt) } + entry(val, lbl) + } + })) + } +} diff --git a/packages/preview/primaviz/0.12.0/src/primitives/matrix.typ b/packages/preview/primaviz/0.12.0/src/primitives/matrix.typ new file mode 100644 index 0000000000..74dcb5a0dd --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/primitives/matrix.typ @@ -0,0 +1,161 @@ +// matrix.typ - Layout for grids with row and column labels (heatmaps, +// correlation matrices, calendars) +// +// The grid's two label gutters are measured from the labels, not guessed from +// theme paddings: the row gutter is as wide as the widest row label, and the +// column gutter is as tall as the column labels need. Column labels stay +// horizontal whenever they fit their cells (shrinking a little first), turn 45 +// degrees (then vertical, shrinking to a floor) only when they cannot, and are +// thinned, evenly with the first and last always shown, only when even those +// would collide. Functions measure text, so call them inside `context`. + +#import "../theme.typ": * +#import "fonts.typ": text-width, text-height, max-text-width, fit-font-size, thin-indices, fit-pitch, pin, place-centered + +/// Lays out the label gutters around a `cell-w` × `cell-h` grid. +/// +/// Returns a dictionary: +/// - `labels`: content to place at the origin of the chart box: row labels +/// right-aligned against the grid, column labels above it +/// - `gx`, `gy`: where the grid's top-left corner goes +/// - `row-w`, `header-h`: gutter sizes (the row gutter includes its gap) +/// - `overhang`: how far the last column label sticks out to the right of the grid +/// - `col-mode`: `"horizontal"`, `"rotated"` (45 degrees) or `"vertical"` +/// - `size`: label size used +/// +/// - row-labels (array): One label per row +/// - col-labels (array): One label per column +/// - cell-w (length): Cell width +/// - cell-h (length): Cell height +/// - theme (dictionary): Resolved theme +/// - max-row-w (none, length): Cap on the row gutter; wider labels shrink, then wrap +/// - label-fill (auto, color): Label colour +/// -> dictionary +#let matrix-layout( + row-labels, + col-labels, + cell-w, + cell-h, + theme, + max-row-w: none, + label-fill: auto, +) = { + let base = theme.axis-label-size + let gap = theme.axis-label-gap + let off = theme.label-offset + 1pt + let fill = if label-fill == auto { theme.text-color } else { label-fill } + let n-rows = row-labels.len() + let n-cols = col-labels.len() + + // --- Row gutter: widest row label, measured --- + // The size shrinks with the cell height before any label is dropped; labels + // that still do not fit are spread evenly, first and last always shown + let r-fit = fit-pitch(cell-h, base, floor: 5pt) + let r-size = r-fit.size + let r-kept = thin-indices(n-rows, r-fit.stride) + let r-shown = row-labels.enumerate().filter(((i, _)) => i in r-kept).map(((_, l)) => l) + let r-widest = r-shown.fold("", (a, b) => if text-width(b, r-size) > text-width(a, r-size) { b } else { a }) + if max-row-w != none and text-width(r-widest, r-size) > max-row-w - gap { + r-size = fit-font-size(r-widest, max-row-w - gap, r-size, min: calc.max(6pt, r-size * 0.75)) + } + let r-w = text-width(r-widest, r-size) + let r-cap = if max-row-w != none { max-row-w - gap } else { r-w } + let r-box = calc.min(r-w, r-cap) + if r-w > r-cap { r-size = calc.max(5pt, calc.min(r-size, cell-h * 0.9 / 2.3)) } + let row-w = r-box + gap + + // --- Column labels --- + // In order: horizontal (shrinking a little); 45 degrees, shrunk to the + // slant's pitch; vertical, shrunk to the cell width; and only then thinned + // (evenly, first and last always shown) at 45 degrees + let c-floor = 6pt + let c-widest = col-labels.fold("", (a, b) => if text-width(b, base) > text-width(a, base) { b } else { a }) + let slack = 4pt + let c-size = fit-font-size(c-widest, cell-w - slack, base, min: calc.max(c-floor, base * 0.8)) + let mode = "horizontal" + let c-kept = array.range(n-cols) + if text-width(c-widest, c-size) > cell-w - slack { + // Rotated labels need 1.2 line heights between neighbours (across the slant + // for 45 degrees), or they read as a hatching + let f45 = fit-pitch(0.7071 * cell-w, base, floor: c-floor, line-k: 1.2) + let f90 = fit-pitch(cell-w, base, floor: c-floor, line-k: 1.2) + if f45.stride == 1 { + mode = "rotated" + c-size = f45.size + } else if f90.stride == 1 { + mode = "vertical" + c-size = f90.size + } else { + mode = "rotated" + c-size = c-floor + c-kept = thin-indices(n-cols, f45.stride) + } + } + let lh = text-height("Mg", c-size) + let header-h = 0pt + let overhang = 0pt + if n-cols > 0 { + let c-shown = col-labels.enumerate().filter(((j, _)) => j in c-kept).map(((_, l)) => l) + let wmax = max-text-width(c-shown, c-size) + if mode == "horizontal" { + header-h = lh + off + } else if mode == "vertical" { + header-h = wmax + off + } else { + header-h = (wmax + lh) * 0.7071 + off + overhang = calc.max(0pt, (text-width(c-shown.last(), c-size) + lh) * 0.7071 - cell-w / 2) + } + } + + let drawn = { + for (i, l) in row-labels.enumerate() { + if i not in r-kept { continue } + place-centered(0pt, header-h + (i + 0.5) * cell-h, + text(size: r-size, fill: fill)[#l], width: r-box, align: right) + } + for (j, l) in col-labels.enumerate() { + if j not in c-kept { continue } + let cx = row-w + (j + 0.5) * cell-w + let body = text(size: c-size, fill: fill)[#l] + if mode == "horizontal" { + place(left + top, dx: cx - text-width(l, c-size) / 2, dy: header-h - off - lh, body) + } else if mode == "vertical" { + place(left + top, dx: cx + lh / 2, dy: header-h - off - lh, + rotate(-90deg, origin: bottom + left, body)) + } else { + place(left + top, dx: cx, dy: header-h - off - lh, + rotate(-45deg, origin: bottom + left, body)) + } + } + } + + ( + labels: drawn, + gx: row-w, + gy: header-h, + row-w: row-w, + header-h: header-h, + overhang: overhang, + col-mode: mode, + size: c-size, + ) +} + +/// One font size for every value written inside the cells of a grid: the +/// largest size, up to `base`, at which the widest value fits a cell. +/// `none` when even the smallest readable size does not fit (the caller then +/// omits the values rather than printing over cell edges). +/// +/// - strs (array): The value strings that will be printed +/// - cell-w (length): Cell width +/// - cell-h (length): Cell height +/// - theme (dictionary): Resolved theme +/// - min (length): Smallest readable size +/// -> none, length +#let cell-label-size(strs, cell-w, cell-h, theme, min: 5pt) = { + let base = calc.min(theme.axis-label-size, cell-h * 0.7) + if strs.len() == 0 { return base } + let widest = strs.fold("", (a, b) => if text-width(b, base) > text-width(a, base) { b } else { a }) + let size = fit-font-size(widest, cell-w - 4pt, base, min: min) + if size < min or text-width(widest, size) > cell-w - 3pt { none } else { size } +} diff --git a/packages/preview/primaviz/0.12.0/src/primitives/paths.typ b/packages/preview/primaviz/0.12.0/src/primitives/paths.typ new file mode 100644 index 0000000000..537f76aa21 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/primitives/paths.typ @@ -0,0 +1,35 @@ +// paths.typ - Shared path helpers + +/// A copy of `stroke` with round joins and caps, so a sharp turn in a thick +/// line closes in an arc instead of a mitre spike that pokes past the data point. +/// +/// - stroke (stroke, length, color): Stroke to round +/// -> stroke +#let round-stroke(stroke) = { + let s = std.stroke(stroke) + std.stroke(paint: s.paint, thickness: s.thickness, dash: s.dash, cap: "round", join: "round") +} + +/// Draws a connected polyline through `points` as a single stroked curve. +/// +/// Emitting one curve instead of one `line()` per segment keeps the corners +/// joined (independent segments meet with butt caps, leaving notches on thick +/// strokes) and keeps dash patterns continuous across the whole series. +/// +/// - points (array): Array of `(x, y)` length tuples +/// - stroke (stroke): Stroke applied to the path +/// -> content +#let draw-polyline(points, stroke) = { + if points.len() < 2 { + return + } + place( + left + top, + curve( + stroke: round-stroke(stroke), + fill: none, + curve.move(points.first()), + ..points.slice(1).map(p => curve.line(p)), + ) + ) +} diff --git a/packages/preview/primaviz/0.12.0/src/primitives/polar-labels.typ b/packages/preview/primaviz/0.12.0/src/primitives/polar-labels.typ new file mode 100644 index 0000000000..fb58c98c1d --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/primitives/polar-labels.typ @@ -0,0 +1,510 @@ +// polar-labels.typ - Label rules shared by the radial charts +// +// Every radial chart answers the same four questions about a label, and +// answers them here so a fix lands everywhere: +// +// readable? label-ink / legible text colour that works on a fill / the page +// inside? fit-wedge-label largest size, best spot, best orientation +// outside? place-outside-labels around a circle, no collisions, leader if moved +// room? polar-fit-radius biggest circle that leaves room for them +// +// A label never overlaps another label or leaves its container: it fits, +// shrinks, moves (with a leader line), or is dropped (the legend, if any, +// still carries it). +// +// All functions measure text, so call them from inside `context`. + +#import "polar.typ": polar-pt +#import "fonts.typ": spread-labels + +// ── Colour ────────────────────────────────────────────────────────────── + +// luminance and contrast-ratio live in util.typ (one WCAG implementation) +#import "../util.typ": luminance, contrast-ratio, contrast-text + +/// Text colour for a label drawn on a filled mark. +/// +/// Takes the theme's own text colours, darker first, and uses the first with +/// at least `min-ratio` contrast against the fill; if neither reaches it, +/// black or white, whichever is stronger. (A fixed "inverse" colour is +/// unreadable on half a palette: white on yellow is 1.7:1.) +/// +/// - fill (color): The mark's fill +/// - theme (dictionary): Resolved theme +/// - min-ratio (float): Contrast ratio worth keeping a theme colour for +/// -> color +#let label-ink(fill, theme, min-ratio: 4.5) = contrast-text(fill, theme: theme, min-ratio: min-ratio) + +/// The colour a mark is drawn in, nudged until text in it is readable on +/// the page: pale palette colours (yellow, pink) vanish as text on white. +/// +/// - c (color): Wanted colour +/// - bg (color): What it is drawn on +/// - min-ratio (float): Contrast ratio to reach (3 is the large-text bar) +/// -> color +#let legible(c, bg, min-ratio: 3.0) = { + let out = c + let lighter-bg = luminance(bg) > 0.35 + for _ in range(10) { + if contrast-ratio(out, bg) >= min-ratio { break } + out = if lighter-bg { out.darken(12%) } else { out.lighten(12%) } + } + out +} + +// ── Text fitting ──────────────────────────────────────────────────────── + +/// Largest font size in `size-min..size-max` at which `make(size)` is at most +/// `max-w` wide (`size-min` if none is). +/// +/// - make (function): `size => content` +/// - max-w (length): Width to fit +/// - size-max (length): Preferred size +/// - size-min (length): Floor size +/// -> length +#let fit-text-size(make, max-w, size-max, size-min, step: 0.5pt) = { + let s = size-max + while s > size-min and measure(make(s)).width > max-w { s -= step } + calc.max(s, size-min) +} + +// ── Inside a wedge ────────────────────────────────────────────────────── + +// Wrap an angle to [-180, 180) +#let _wrap(d) = calc.rem-euclid(d + 180, 360) - 180 + +// Keep text upright: an angle in [-90, 90] (so a vertical label on the left +// of a circle reads upward, on the right downward: tops face outward) +#let _upright(d) = { + let a = _wrap(d) + if a > 90 { a - 180 } else if a < -90 { a + 180 } else { a } +} + +/// Whether a `w`×`h` label, centred at distance `r` along `mid`, lies inside +/// the wedge (`r-in..r-out`, `mid ± half-span`) with `pad` to spare. +/// +/// `rot` turns the label (degrees clockwise, as on screen). Exact for +/// convex wedges (half-span < 90deg, which is any label worth fitting). +/// +/// - r (length): Distance of the label centre from the chart centre +/// - mid (float): Direction of the label centre, degrees +/// - w (length): Label width +/// - h (length): Label height +/// - r-in (length): Inner radius of the wedge (0 for a pie slice) +/// - r-out (length): Outer radius of the wedge +/// - half-span (float): Half the wedge's sweep, degrees +/// - rot (float): Label rotation in degrees +/// - pad (length): Clearance to keep from every edge +/// -> bool +#let rect-in-wedge(r, mid, w, h, r-in, r-out, half-span, rot: 0, pad: 1pt) = { + let (r, w, h, r-in, r-out, pad) = (r, w, h, r-in, r-out, pad).map(x => x / 1pt) + let (px, py) = (r * calc.cos(mid * 1deg), r * calc.sin(mid * 1deg)) + let (cr, sr) = (calc.cos(rot * 1deg), calc.sin(rot * 1deg)) + let ok = true + for (sx, sy) in ((-1, -1), (1, -1), (1, 1), (-1, 1)) { + let (u, v) = (sx * w / 2, sy * h / 2) + let (x, y) = (px + u * cr - v * sr, py + u * sr + v * cr) + let rho = calc.sqrt(x * x + y * y) + if rho > r-out - pad { ok = false } + if half-span < 90 and rho > 0.001 { + let off = calc.abs(_wrap(calc.atan2(x, y).deg() - mid)) + if off > half-span - pad / rho * 57.2958 { ok = false } + } + } + if ok and r-in > 0 { + // the rectangle's nearest point to the centre must clear the hole + let (lu, lv) = (-px * cr - py * sr, px * sr - py * cr) + let (du, dv) = (lu - calc.max(-w / 2, calc.min(w / 2, lu)), lv - calc.max(-h / 2, calc.min(h / 2, lv))) + if calc.sqrt(du * du + dv * dv) < r-in + pad { ok = false } + } + ok +} + +/// Finds the biggest label that fits inside a wedge and where it sits. +/// +/// Tries `make(size)` from `size-max` down to `size-min`, horizontal first; +/// with `rotate`, a label that fits no size horizontally is tried along the +/// radius and then along the arc (turned to stay upright). At each size the +/// spot nearest `r-pref` that fits wins. Returns `none` when nothing fits: +/// the caller moves the label outside, or drops it. +/// +/// - make (function): `size => content` +/// - mid (float): Direction of the wedge's middle, degrees +/// - half-span (float): Half the wedge's sweep, degrees +/// - r-in (length): Inner radius of the wedge (0 for a pie slice) +/// - r-out (length): Outer radius of the wedge +/// - size-max (length): Preferred label size +/// - size-min (length): Smallest size worth printing +/// - r-pref (auto, length): Preferred distance of the label from the centre +/// - rotate (bool): Allow turning the label +/// - pad (length): Clearance from the wedge's edges +/// -> none, dictionary with `size`, `r`, `rot`, `w`, `h` +#let fit-wedge-label(make, mid, half-span, r-in, r-out, size-max, size-min, r-pref: auto, rotate: false, pad: 1.2pt) = { + let pref = if r-pref == auto { (r-in + r-out) / 2 } else { r-pref } + let step = (r-out - r-in) / 10 + let radii = () + for k in range(0, 11) { + for sign in (1, -1) { + let r = pref + sign * k * step + if r >= r-in and r <= r-out and r not in radii { radii.push(r) } + } + } + let sizes = () + let s = size-max + while s >= size-min { sizes.push(s); s -= 0.5pt } + if sizes.len() == 0 { sizes.push(size-min) } + let dims = sizes.map(s => measure(make(s))) + + let rots = ((0,),) + if rotate { rots.push((_upright(mid), _upright(mid + 90))) } + let found = none + for rs in rots { + for (i, size) in sizes.enumerate() { + for rot in rs { + for r in radii { + if found == none and rect-in-wedge(r, mid, dims.at(i).width, dims.at(i).height, r-in, r-out, half-span, rot: rot, pad: pad) { + found = (size: size, r: r, rot: rot, w: dims.at(i).width, h: dims.at(i).height) + } + } + } + } + } + found +} + +/// Breaks a label wider than `max-w` onto several lines, the lines leaning +/// toward the mark it names (right-aligned on the left of a circle, left-aligned +/// on the right). Without this one long name pushes a whole chart small. +/// +/// - body (content): Label +/// - max-w (length): Widest the label may be +/// - angle (float): Degrees clockwise from 3 o'clock of the mark it names +/// -> content +#let wrap-label(body, max-w, angle) = { + if measure(body).width <= max-w { body } else { + let c = calc.cos(angle * 1deg) + let al = if c < -0.3 { right } else if c > 0.3 { left } else { center } + box(width: max-w, align(al, par(justify: false, leading: 0.4em, body))) + } +} + +/// Places `body` centred on (`x`, `y`), optionally turned by `rot` degrees +/// clockwise (see `fit-wedge-label`). +/// +/// - x (length): Centre x +/// - y (length): Centre y +/// - w (length): Label width +/// - h (length): Label height +/// - body (content): Label +/// - rot (float): Rotation, degrees clockwise +/// -> content +#let place-centred(x, y, w, h, body, rot: 0) = { + let b = box(width: w + 1pt, height: h, align(center + horizon, body)) + place(left + top, dx: x - w / 2 - 0.5pt, dy: y - h / 2, + if rot == 0 { b } else { rotate(rot * 1deg, b) }) // Typst turns positive angles clockwise, like screen angles +} + +// ── Outside a circle ──────────────────────────────────────────────────── + +// Top-left of a w×h label for a mark at (mx, my): a continuous slide from +// left of the point (angle 0) to centred above/below it (angle ±90°) to +// right of it (angle 180°), then nudged outward until no part of the label +// is nearer the mark than `gap` (a big label at a diagonal would otherwise +// graze the mark it names). +#let _slot(angle, w, h, mx, my, gap) = { + let (c, s) = (calc.cos(angle * 1deg), calc.sin(angle * 1deg)) + let (x, y) = (mx + gap * c - w * (1 - c) / 2, my + gap * s - h * (1 - s) / 2) + for _ in range(6) { + let (ex, ey) = (calc.max(x - mx, 0pt, mx - x - w), calc.max(y - my, 0pt, my - y - h)) + let d = calc.sqrt((ex / 1pt) * (ex / 1pt) + (ey / 1pt) * (ey / 1pt)) * 1pt + if d >= gap - 0.05pt { break } + // How fast moving along (c, s) opens the gap, given where the nearest point is + let (ux, uy) = (if x > mx { 1 } else { -1 }, if y > my { 1 } else { -1 }) + let rate = if d == 0pt { 1.0 } else { calc.max(0.25, (ux * ex / 1pt * c + uy * ey / 1pt * s) / (d / 1pt)) } + x += (gap - d) / rate * c + y += (gap - d) / rate * s + } + (x, y) +} + +// Length of the vector (dx, dy) +#let _dist(dx, dy) = calc.sqrt((dx / 1pt) * (dx / 1pt) + (dy / 1pt) * (dy / 1pt)) * 1pt + +// Whether two label rectangles come within `margin` of each other +#let _touch(a, b, margin) = ( + a.lx < b.lx + b.w + margin and b.lx < a.lx + a.w + margin + and a.ly < b.ly + b.h + margin and b.ly < a.ly + a.h + margin +) + +// Top-left of mark `m`'s label when the mark is read as sitting at `phi` +// degrees on its circle, though it really sits at `theta`, with the label on +// row `lvl` (0: against the circle; each row beyond stands one label's depth +// further out). A label slid along the circle also stands a little further +// out the further it slid (up to 10pt), so the leader lines of a fan clear +// the labels they pass. +#let _rect(m, phi, theta, lvl, cx, cy, gap) = { + let (ax, ay) = polar-pt(cx, cy, m.ar, phi) + let slid = calc.abs(phi - theta) / 57.2958 * m.ar + let (c, s) = (calc.cos(phi * 1deg), calc.sin(phi * 1deg)) + let lift = lvl * (m.w * calc.abs(c) + m.h * calc.abs(s) + 2pt) + let (lx, ly) = _slot(phi, m.w, m.h, ax, ay, gap + calc.min(10pt, 0.3 * slid) + lift) + (lx: lx, ly: ly) +} + +// Lays out every label round its circle, keeping the order of the marks. +// +// Labels whose marks are close enough to collide are slid along the circle +// until none comes within `sep` of another, each as near its own mark as it +// can stay: a cluster at 12 or 6 o'clock fans out sideways, one at 3 or 9 +// o'clock up and down, one in between both. The room two neighbours need is +// their extent along the circle's tangent (a w×h box seen along direction +// `psi` measures w|sin| + h|cos|) as an angle at their distance from the +// centre; the nearest layout that gives every neighbour that room is a +// pool-adjacent-violators fit. Neighbours still touching afterwards (the +// estimate is a little off at diagonals) are given more room and the fit is +// repeated. +// +// When the crowd is too long for one row (a ring of labels is only so long), +// the labels of each cluster take turns on two or three rows, stepping out +// from the circle, and only labels on the same row need room along it. +// The fewest rows that leave no label touching another, inside `bounds` +// (none: no limit), and slid no further than a radius, win. +// +// Returns the marks, each with its final `lx`, `ly`, angle `phi` and row `lv`. +#let _fan(marks, cx, cy, gap, sep, bounds) = { + let n = marks.len() + if n == 0 { return () } + // By angle, starting after the widest empty arc, so a cluster that + // straddles 3 o'clock is not cut in two + let norm = marks.map(m => calc.rem-euclid(m.angle, 360)) + let by-angle = array.range(n).sorted(key: i => (norm.at(i), i)) + let first = 0 + let widest = -1.0 + for k in range(n) { + let next = if k + 1 < n { norm.at(by-angle.at(k + 1)) } else { norm.at(by-angle.at(0)) + 360 } + if next - norm.at(by-angle.at(k)) > widest { + widest = next - norm.at(by-angle.at(k)) + first = calc.rem(k + 1, n) + } + } + let seq = range(n).map(k => by-angle.at(calc.rem(first + k, n))) + let ms = seq.map(i => marks.at(i)) + let th = () + let wrapped = false + for (k, i) in seq.enumerate() { + if k > 0 and norm.at(i) < norm.at(seq.at(k - 1)) { wrapped = true } + let a = norm.at(i) + th.push(if wrapped { a + 360 } else { a }) + } + + let rects(phi, lv) = range(n).map(k => { + let at = _rect(ms.at(k), phi.at(k), th.at(k), lv.at(k), cx, cy, gap) + (: ..ms.at(k), ..at) + }) + let fitted(phi, lv) = rects(phi, lv).enumerate().map(((k, p)) => { + let q = (: ..p, phi: phi.at(k), th: th.at(k), lv: lv.at(k)) + if bounds == none { q } else { + (: ..q, lx: calc.max(bounds.left, calc.min(bounds.right - p.w, p.lx)), + ly: calc.max(bounds.top, calc.min(bounds.bottom - p.h, p.ly))) + } + }) + + // Where each label stands in the run of neighbours it touches (0: first or alone) + let home = rects(th, (0,) * n) + let pool = (0,) * n + for k in range(1, n) { + if _touch(home.at(k - 1), home.at(k), sep) { pool.at(k) = pool.at(k - 1) + 1 } + } + + let limit = calc.max(..marks.map(m => m.ar)) + let best = none + for rows in (1, 2, 3) { + let lv = pool.map(j => calc.rem(j, rows)) + let phi = th + let grow = (1.0,) * n + for round in range(16) { + let rs = rects(phi, lv) + // Label k is squeezed when it, or a label on the same row after it, + // touches one before it + let tight = (false,) * n + for a in range(n) { + for b in range(a + 1, n) { + if lv.at(a) == lv.at(b) and _touch(rs.at(a), rs.at(b), sep) { + for c in range(a + 1, b + 1) { if lv.at(c) == lv.at(a) { tight.at(c) = true } } + } + } + } + if not tight.any(t => t) { break } + + for row in range(rows) { + let idx = range(n).filter(k => lv.at(k) == row) + if idx.len() < 2 { continue } + // Degrees each label needs beyond the one before it + let steps = (0.0,) + for j in range(1, idx.len()) { + let (ka, kb) = (idx.at(j - 1), idx.at(j)) + if round > 0 and tight.at(kb) { grow.at(kb) *= 1.25 } + let (a, b) = (rs.at(ka), rs.at(kb)) + let psi = (phi.at(ka) + phi.at(kb)) / 2 * 1deg + let along(m) = m.w * calc.abs(calc.sin(psi)) + m.h * calc.abs(calc.cos(psi)) + let reach = calc.max(1pt, (_dist(a.lx + a.w / 2 - cx, a.ly + a.h / 2 - cy) + _dist(b.lx + b.w / 2 - cx, b.ly + b.h / 2 - cy)) / 2) + let need = ((along(a) + along(b)) / 2 + sep) / (2 * reach) + steps.push(2 * calc.asin(calc.min(1.0, need)).deg() * grow.at(kb)) + } + // The nearest layout with that room between neighbours (degrees as points) + let at = spread-labels(idx.map(k => th.at(k) * 1pt), need: (a, b) => steps.at(b) * 1pt) + for (j, k) in idx.enumerate() { phi.at(k) = at.at(j) / 1pt } + } + } + + let placed = fitted(phi, lv) + let clashes = 0 + for a in range(n) { + for b in range(a + 1, n) { + if _touch(placed.at(a), placed.at(b), 0pt) { clashes += 1 } + } + } + let slide = calc.max(..range(n).map(k => calc.abs(phi.at(k) - th.at(k)) / 57.2958 * ms.at(k).ar)) + if best == none or (clashes, slide) < (best.clashes, best.slide) { + best = (placed: placed, clashes: clashes, slide: slide) + } + if clashes == 0 and slide <= limit { break } + } + best.placed +} + +// Every label that can be shown without touching another or leaving +// `bounds` (none: no limit), placed. When a crowd cannot be fitted, the label +// of lowest priority among those still touching is dropped and the rest laid +// out again. +#let _layout-outside(marks, cx, cy, gap, bounds: none) = { + let live = marks + let out = () + for _ in range(marks.len() + 1) { + let placed = _fan(live, cx, cy, gap, 1.5pt, bounds) + let weakest = none + for (a, p) in placed.enumerate() { + for q in placed.slice(a + 1) { + if _touch(p, q, 0pt) { + for c in (p, q) { + if weakest == none or (c.priority, -c.idx) < (weakest.priority, -weakest.idx) { weakest = c } + } + } + } + } + if weakest == none { + out = placed + break + } + live = live.filter(m => m.idx != weakest.idx) + } + out +} + +// Measured size (with a hair of slack) of every item's label +#let _sizes(items) = items.map(it => { + let m = measure(it.body) + (m.width + 1pt, m.height) +}) + +#let _marks(items, sizes, r) = items.zip(sizes).enumerate().map(((i, (it, (w, h)))) => ( + idx: i, angle: it.angle, ar: it.at("r", default: r), w: w, h: h, src: it.at("from", default: none), + priority: it.at("priority", default: 0), body: it.body)) + +/// The box, centred on the chart, that holds a circle of radius `r` and every +/// outside label around it (see `place-outside-labels`): `x` and `y` are its +/// half-width and half-height; `left`, `right`, `top` and `bottom` how far +/// the labels (or the circle) reach on each side, without `pad`. +/// +/// - items (array): Dictionaries with `angle` (degrees) and `body` +/// - r (length): Circle radius +/// - gap (length): Distance between circle and label +/// - pad (length): Clearance beyond the labels +/// -> dictionary +#let polar-label-extent(items, r, gap: 4pt, pad: 1pt) = { + let (left, right, top, bottom) = (r, r, r, r) + for p in _layout-outside(_marks(items, _sizes(items), r), 0pt, 0pt, gap) { + left = calc.max(left, -p.lx) + right = calc.max(right, p.lx + p.w) + top = calc.max(top, -p.ly) + bottom = calc.max(bottom, p.ly + p.h) + } + (x: calc.max(left, right) + pad, y: calc.max(top, bottom) + pad, left: left, right: right, top: top, bottom: bottom) +} + +/// Biggest circle radius at which every outside label (see +/// `place-outside-labels`) still fits inside a `box-w`×`box-h` box centred +/// on (`cx`, `cy`). +/// +/// - items (array): Dictionaries with `angle` (degrees) and `body` +/// - cx (length): Centre x +/// - cy (length): Centre y +/// - box-w (length): Box width +/// - box-h (length): Box height +/// - gap (length): Distance between circle and label +/// - pad (length): Clearance from the box's edges +/// -> length +#let polar-fit-radius(items, cx, cy, box-w, box-h, gap: 4pt, pad: 1pt) = { + let sizes = _sizes(items) + // How far the labels poke out of the box when the circle has radius r + let over(r) = { + let worst = r - (calc.min(cx, cy, box-w - cx, box-h - cy) - pad) + for p in _layout-outside(_marks(items, sizes, r), cx, cy, gap) { + worst = calc.max(worst, p.lx + p.w - (box-w - pad), pad - p.lx, p.ly + p.h - (box-h - pad), pad - p.ly) + } + worst + } + // Bisection: `over` only grows with r + let (lo, hi) = (calc.min(cx, cy) * 0.25, calc.min(cx, cy, box-w - cx, box-h - cy) - pad) + if over(hi) <= 0pt { hi } else { + for _ in range(12) { + let mid = (lo + hi) / 2 + if over(mid) <= 0pt { lo = mid } else { hi = mid } + } + lo + } +} + +/// Labels around a circle: each label sits just beyond its own mark. Labels +/// that would collide are slid round the circle (sideways at the top and +/// bottom, up and down at the sides) until they clear each other, a label +/// that moved gets a leader line back to its mark, and a label that still +/// cannot be placed without touching another is dropped (lowest `priority` +/// first). +/// +/// - items (array): Dictionaries with `angle` (degrees), `body` (content), +/// and optionally `r` (where the mark ends, default `r`), `priority` +/// (default 0; higher survives) and `from` (a radius: the mark lies there, +/// deeper inside the chart than its label, so the label always gets a +/// leader line from that point) +/// - cx (length): Centre x +/// - cy (length): Centre y +/// - r (length): Default radius of the marks +/// - bounds (dictionary): `left`, `right`, `top`, `bottom` the labels must stay inside +/// - gap (length): Distance between mark and label +/// - leader-stroke (stroke): Style of the leader lines +/// - tol (length): How far a label may slide before it earns a leader +/// -> content +#let place-outside-labels(items, cx, cy, r, bounds, gap: 4pt, leader-stroke: 0.5pt + gray, tol: 1.5pt) = { + let marks = _marks(items, _sizes(items), r) + for p in _layout-outside(marks, cx, cy, gap, bounds: bounds) { + let c = calc.cos(p.phi * 1deg) + let (ax, ay) = polar-pt(cx, cy, if p.src == none { p.ar } else { p.src }, p.angle) + let home = _rect(p, p.th, p.th, 0, cx, cy, gap) + let moved = p.src != none or calc.abs(p.ly - home.ly) > tol or calc.abs(p.lx - home.lx) > tol + let h-align = if c < -0.3 { right } else if c > 0.3 { left } else { center } + place(left + top, dx: p.lx, dy: p.ly, + box(width: p.w, height: p.h, align(h-align + horizon, p.body))) + // Leader from the mark to the nearest point of the label + let (tx, ty) = (calc.max(p.lx, calc.min(p.lx + p.w, ax)), calc.max(p.ly, calc.min(p.ly + p.h, ay))) + let (dx, dy) = ((tx - ax) / 1pt, (ty - ay) / 1pt) + let len = calc.sqrt(dx * dx + dy * dy) + if moved and len > 3 { + place(left + top, line(start: (ax, ay), end: (tx - dx / len * 1.5pt, ty - dy / len * 1.5pt), stroke: leader-stroke)) + // A mark inside the chart, away from its edge, gets a dot to say where it starts + if p.src != none { + place(left + top, dx: ax - 1pt, dy: ay - 1pt, circle(radius: 1pt, fill: stroke(leader-stroke).paint, stroke: none)) + } + } + } +} diff --git a/packages/preview/primaviz/0.12.0/src/primitives/polar.typ b/packages/preview/primaviz/0.12.0/src/primitives/polar.typ new file mode 100644 index 0000000000..f80dd5fde1 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/primitives/polar.typ @@ -0,0 +1,131 @@ +// polar.typ - Polar/radial drawing primitives for circular charts +// +// Geometry only: points, wedges, rings. Label placement lives in +// polar-labels.typ, frame/legend sizing in radial.typ. + +/// Returns an array of (x, y) points approximating an arc. +/// +/// - cx (length): Center x +/// - cy (length): Center y +/// - r (length): Radius +/// - start-deg (float): Start angle in degrees +/// - end-deg (float): End angle in degrees +/// - steps (auto, int): Number of line segments; auto computes from arc span +/// -> array +#let arc-points(cx, cy, r, start-deg, end-deg, steps: auto) = { + let span = end-deg - start-deg + let n = if steps == auto { calc.max(int(calc.abs(span) / 5), 3) } else { steps } + array.range(n + 1).map(s => { + let a = (start-deg + span * s / n) * 1deg + (cx + r * calc.cos(a), cy + r * calc.sin(a)) + }) +} + +/// The point at `r` from the centre in direction `deg` +/// (0 = right, 90 = down, -90 = up). +/// +/// - cx (length): Center x +/// - cy (length): Center y +/// - r (length): Distance from the centre +/// - deg (float): Angle in degrees +/// -> array +#let polar-pt(cx, cy, r, deg) = (cx + r * calc.cos(deg * 1deg), cy + r * calc.sin(deg * 1deg)) + +/// Returns polygon points for a pie slice (center + outer arc). +/// +/// - cx (length): Center x +/// - cy (length): Center y +/// - r (length): Radius +/// - start-deg (float): Start angle in degrees +/// - end-deg (float): End angle in degrees +/// -> array +#let pie-slice-points(cx, cy, r, start-deg, end-deg) = { + let span = end-deg - start-deg + let steps = calc.max(int(calc.abs(span) / 3), 3) + ((cx, cy),) + arc-points(cx, cy, r, start-deg, end-deg, steps: steps) +} + +/// Returns polygon points for a filled annular wedge (donut slice). +/// Outer arc runs start→end, inner arc runs end→start. +/// +/// - cx (length): Center x +/// - cy (length): Center y +/// - r-inner (length): Inner radius +/// - r-outer (length): Outer radius +/// - start-deg (float): Start angle in degrees +/// - end-deg (float): End angle in degrees +/// -> array +#let annular-wedge-points(cx, cy, r-inner, r-outer, start-deg, end-deg) = { + let outer = arc-points(cx, cy, r-outer, start-deg, end-deg) + let inner = arc-points(cx, cy, r-inner, end-deg, start-deg) + outer + inner +} + +/// Draws one wedge: a pie slice (`r-inner` = 0) or a donut/ring segment. +/// +/// A sweep of a full turn is drawn as a circle or a thick-stroked circle, +/// not a polygon, so it has no seam where its two ends meet. Nothing is +/// drawn for an empty sweep, so a zero value leaves no stray hairline. +/// +/// - cx (length): Center x +/// - cy (length): Center y +/// - r-inner (length): Inner radius (0 for a pie slice) +/// - r-outer (length): Outer radius +/// - start-deg (float): Start angle in degrees +/// - end-deg (float): End angle in degrees +/// - fill (color): Fill +/// - stroke (stroke): Outline (the separator between neighbours) +/// -> content +#let place-wedge(cx, cy, r-inner, r-outer, start-deg, end-deg, fill, stroke: none) = { + let span = calc.abs(end-deg - start-deg) + if span < 0.01 or r-outer <= r-inner { + none + } else if span >= 359.99 and r-inner <= 0pt { + place(left + top, dx: cx - r-outer, dy: cy - r-outer, + circle(radius: r-outer, fill: fill, stroke: stroke)) + } else if span >= 359.99 { + let rm = (r-inner + r-outer) / 2 + place(left + top, dx: cx - rm, dy: cy - rm, + circle(radius: rm, fill: none, stroke: (r-outer - r-inner) + fill)) + } else { + let pts = if r-inner <= 0pt { pie-slice-points(cx, cy, r-outer, start-deg, end-deg) } + else { annular-wedge-points(cx, cy, r-inner, r-outer, start-deg, end-deg) } + place(left + top, polygon(fill: fill, stroke: stroke, ..pts)) + } +} + +/// Draws a band along a circle (a progress ring, a gauge dial): a filled +/// annular sector, optionally with round ends. A single filled shape, so +/// unlike a chain of thick line segments it has no seams or ticks. +/// +/// - cx (length): Center x +/// - cy (length): Center y +/// - r (length): Radius of the band's centre line +/// - width (length): Band thickness +/// - start-deg (float): Start angle in degrees +/// - end-deg (float): End angle in degrees +/// - fill (color): Fill +/// - round (bool): Round both ends +/// -> content +#let place-arc-band(cx, cy, r, width, start-deg, end-deg, fill, round: false) = { + place-wedge(cx, cy, calc.max(0pt, r - width / 2), r + width / 2, start-deg, end-deg, fill) + let span = calc.abs(end-deg - start-deg) + if round and span > 0.01 and span < 359.99 { + for deg in (start-deg, end-deg) { + let (x, y) = polar-pt(cx, cy, r, deg) + place(left + top, dx: x - width / 2, dy: y - width / 2, + circle(radius: width / 2, fill: fill, stroke: none)) + } + } +} + +/// Returns the separator stroke for adjacent filled segments. +/// Uses theme background if set (blends with page), otherwise white. +/// +/// - theme (dictionary): Resolved theme +/// - thickness (length): Stroke width +/// -> stroke +#let separator-stroke(theme, thickness: 0.5pt) = { + let color = if theme.background != none { theme.background } else { white } + thickness + color +} diff --git a/packages/preview/primaviz/0.12.0/src/primitives/radial.typ b/packages/preview/primaviz/0.12.0/src/primitives/radial.typ new file mode 100644 index 0000000000..be587be975 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/primitives/radial.typ @@ -0,0 +1,132 @@ +// radial.typ - Frame helpers shared by the radial charts +// +// What every radial chart sets up around its circle: a side legend sized to +// its entries, a width budget, a title scaled to the chart. + +#import "../theme.typ": get-color +#import "legend.typ": draw-legend-vertical +#import "container.typ": container-inset + +/// A vertical legend sized to its entries, placed beside the chart by +/// `chart-container`. Returns `(legend, width, height, theme)`: pass `legend` +/// and `width` as the container's `legend` and `legend-width`, and `theme` (with +/// `legend-position` set to `position`). The container grows to a legend taller +/// than the chart; `height` is for charts that want to know. +/// +/// A legend taller than `max-height` flows into several columns (down each +/// column, then across), as few as hold it, balanced, and no more than +/// `max-total` of width allows; what still does not fit makes a taller +/// legend, and the container grows to hold it. With `max-height` +/// left `auto` the legend is always one column. The result also has `cols`, +/// the number of columns. +/// +/// - entries (array): Strings, or dictionaries with `name` (and `color`) +/// - theme (dictionary): Resolved theme +/// - enabled (bool): False yields no legend +/// - min-width (length): Narrowest a legend column may be +/// - max-width (length): Widest a legend column may be +/// - position (str): `"right"` or `"left"` +/// - max-height (auto, length): Tallest a column should get before the legend flows into another one (the chart's height) +/// - max-total (auto, length): Widest the whole legend may get with several columns (see `legend-room`) +/// -> dictionary +#let side-legend(entries, theme, enabled: true, min-width: 60pt, max-width: 180pt, position: "right", max-height: auto, max-total: auto) = { + if not enabled or entries.len() == 0 { + (legend: none, width: 0pt, height: 0pt, theme: theme, cols: 0) + } else { + let names = entries.map(e => if type(e) == dictionary and "name" in e { e.name } else { e }) + let widest = calc.max(..names.map(n => measure(text(size: theme.legend-size)[#n]).width)) + let w = calc.max(min-width, calc.min(max-width, widest + theme.legend-swatch-size + 5pt)) + let legend = draw-legend-vertical(entries, theme, width: w) + let themed = theme + themed.insert("legend-position", position) + let measured(c) = measure(c).height + let (n, gutter) = (entries.len(), 10pt) + let h1 = measured(legend) + // Columns: as many as the height asks for, as many as the width allows + let cols = if max-height == auto or max-height <= 0pt or h1 <= max-height { 1 } else { + let want = calc.min(n, 6, calc.ceil(h1 / max-height)) + let room = if max-total == auto { want } else { calc.max(1, calc.floor((max-total + gutter) / (w + gutter))) } + calc.min(want, room) + } + if cols > 1 { + // Balanced columns, with their colours fixed first so a column that + // starts mid-list does not start over in the palette + let rows = calc.ceil(n / cols) + let fixed = entries.enumerate().map(((i, e)) => if type(e) == dictionary and "color" in e { e } else { + (name: if type(e) == dictionary and "name" in e { e.name } else { e }, color: get-color(theme, i)) + }) + let chunks = range(0, n, step: rows).map(i => fixed.slice(i, calc.min(i + rows, n))) + let flow = grid(columns: (w,) * chunks.len(), column-gutter: gutter, ..chunks.map(c => draw-legend-vertical(c, theme, width: w))) + (legend: flow, width: chunks.len() * w + (chunks.len() - 1) * gutter, height: measured(flow), theme: themed, cols: chunks.len()) + } else { + (legend: legend, width: w, height: h1, theme: themed, cols: 1) + } + } +} + +/// Width a side legend of several columns may take: what the container has, +/// less the chart at 70% of its wanted size (it shrinks that much before a +/// taller legend is preferred), the labels around it and the gap between +/// them. `auto` for an unbounded region. Pass it as `side-legend`'s +/// `max-total`; check the result against the chart once its labels are laid +/// out (`legend-left`). +/// +/// - size (length): Wanted chart width +/// - avail-w (length): Width of the containing region +/// - theme (dictionary): Resolved theme (for the container's inset) +/// - margin (length): Width the chart's labels take on both sides together +/// -> auto, length +#let legend-room(size, avail-w, theme, margin: 40pt) = { + if type(avail-w) != length or avail-w <= 0pt or avail-w > 4000pt { auto } + else { calc.max(0pt, avail-w - 2 * theme.at("container-inset", default: container-inset) - 0.7 * size - margin - 10pt) } +} + +/// What is left of the container's width for a side legend once the chart +/// body (circle and its labels) has its final width: pass it as `max-total` +/// to rebuild a legend that came out too wide. `auto` for an unbounded +/// region. +/// +/// - body-w (length): Width of the chart body, labels included +/// - avail-w (length): Width of the containing region +/// - theme (dictionary): Resolved theme (for the container's inset) +/// -> auto, length +#let legend-left(body-w, avail-w, theme) = { + if type(avail-w) != length or avail-w <= 0pt or avail-w > 4000pt { auto } + else { calc.max(0pt, avail-w - 2 * theme.at("container-inset", default: container-inset) - body-w - 10pt) } +} + +/// Splits the available width between a chart and what sits beside it +/// (legend column, label margins) so both stay inside the container. +/// The chart shrinks first, down to `floor`; below that the side shrinks too. +/// Returns `(chart-size, side-width)`. +/// +/// - size (length): Wanted chart width +/// - side (length): Wanted width of everything beside the chart +/// - avail-w (length): Width of the containing region +/// - theme (dictionary): Resolved theme (for the container's inset) +/// - floor (length): Smallest chart worth drawing before the side gives way +/// -> array +#let split-width(size, side, avail-w, theme, floor: 80pt) = { + if type(avail-w) != length or avail-w <= 0pt { return (size, side) } + let budget = avail-w - 2 * theme.at("container-inset", default: container-inset) + if size + side <= budget { (size, side) } + else if side == 0pt { (calc.max(budget, 20pt), 0pt) } + else if budget - side >= floor { (budget - side, side) } + else { (budget * 0.55, budget * 0.45) } +} + +/// The theme with its title scaled to a small chart: a full-size bold title +/// over an 80pt dial shouts over the heading of the group the dial sits in. +/// Charts of 130pt and more keep the theme's title size. +/// +/// - theme (dictionary): Resolved theme +/// - chart-size (length): The chart's diameter or width +/// -> dictionary +#let title-for-size(theme, chart-size) = { + let s = calc.max(theme.axis-label-size, calc.min(theme.title-size, chart-size * 0.1)) + if s >= theme.title-size { theme } else { + let scaled = theme + scaled.insert("title-size", s) + scaled + } +} diff --git a/packages/preview/primaviz/0.12.0/src/primitives/row-chart.typ b/packages/preview/primaviz/0.12.0/src/primitives/row-chart.typ new file mode 100644 index 0000000000..2fa8920eb7 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/primitives/row-chart.typ @@ -0,0 +1,113 @@ +// row-chart.typ - Scaffold of the horizontal bar and lollipop charts +// +// Both draw one mark per row, from a zero line to the datum, with the value +// beside the mark's end. Everything around the marks comes from the row +// primitives (rows.typ): the measured label column and the dense-row policy +// (`row-layout`), the value axis (`draw-x-axis`), the value labels +// (`end-label`) and the natural size of a row chart (`resolve-size(rows: true)`). +// A chart only says how a mark looks. + +#import "../theme.typ": get-color +#import "../util.typ": nice-ticks, nonzero +#import "container.typ": chart-container +#import "layout.typ": resolve-size, max-text-width +#import "rows.typ": row-layout, row-y, end-reserves, end-label, linear-x-ticks, draw-x-axis, x-axis-extent, tick-label-width +#import "axes.typ": value-text, fit-row-size, draw-axis-titles +#import "annotations.typ": draw-annotations, annotation-reserve + +/// Lays out and draws a row chart; call inside `context` and `layout`. +/// +/// Every row gets its category label: the row pitch grows with the row count +/// when `height` is `auto`; with a fixed height the label size shrinks to +/// `category-label-min-size` (theme key, 5pt unless set) and only then are labels +/// thinned out, evenly, the first and the last always shown. +/// +/// - labels (array): Row labels +/// - values (array): One value per row +/// - width (auto, length, ratio, relative): Chart width +/// - height (auto, length, ratio, relative): Chart height +/// - size (dictionary): Available region from `layout(size => ...)` +/// - t (dictionary): Resolved theme +/// - title (none, content): Chart title +/// - x-label (none, content): Value axis title +/// - y-label (none, content): Category axis title +/// - annotations (none, array): Annotation descriptors +/// - extra-legend-separation (length): Extra space between legend and chart +/// - show-values (bool): Write each value beside the end of its mark +/// - reach (length): How far a mark sticks out beyond its value (a dot's radius) +/// - draw-mark (function): `(i, y, x-from, x-to, color, row-h) => content`, the mark of +/// row `i` centred on `y`, from the zero line to the value +/// -> content +#let row-chart( + labels, + values, + width, + height, + size, + t, + title: none, + x-label: none, + y-label: none, + annotations: none, + extra-legend-separation: 0pt, + show-values: true, + reach: 0pt, + draw-mark, +) = { + let n = values.len() + let (width, height) = resolve-size(width, height, size, n: n, theme: t, rows: true) + let strs = values.map(v => value-text(v, t)) + let (lo, hi) = (calc.min(0, ..values), calc.max(0, ..values)) + let nt = nice-ticks(lo, if lo == hi { 1 } else { hi }, count: t.tick-count) + let (alo, ahi) = (nt.min, nt.max) + let span = nonzero(ahi - alo) + + // A rotated category title takes a band at the far left + let band = if y-label == none { 0pt } else { measure(text(size: t.axis-title-size)[#y-label]).height + t.axis-title-gap } + let inner-w = width - band + + // Rows: the measured label column, the axis band below, room above for annotation labels + let ann = annotation-reserve(annotations, t) + let pad-top = if annotations != none { calc.max(t.axis-padding-top, ann.top) } else { t.label-offset } + let axis-h = x-axis-extent(t, title: x-label) + let layout-with(inset-l, inset-r) = row-layout(inner-w, height, labels, t, + label-frac: 0.38, pad-top: pad-top, pad-bottom: axis-h, inset-left: inset-l, inset-right: inset-r, + min-label-size: t.at("category-label-min-size", default: 5pt)) + let probe = layout-with(0pt, 0pt) + + // Room past the extreme marks for their value labels and the end tick labels, + // solved from where the extreme marks sit + let value-w = if show-values { max-text-width(strs, t.value-label-size) + t.label-offset } else { 0pt } + let (need-lo, need-hi) = ((if lo < 0 { value-w } else { 0pt }) + reach, (if hi > 0 { value-w } else { 0pt }) + reach) + let tick-half = tick-label-width(linear-x-ticks(nt, alo, ahi, 0pt, 1pt, t), t) / 2 + let reserve = end-reserves( + inner-w - probe.label-w - probe.gap, (lo - alo) / span, need-lo, (ahi - hi) / span, need-hi, + floor: tick-half) + let lay = layout-with(reserve.lo, reserve.hi + ann.right) + let (x0, x1) = (lay.x0, lay.x1) + let x-of(v) = x0 + (v - alo) / span * lay.plot-w + let vsize = if show-values { fit-row-size(lay.row-h, t.value-label-size) } + + chart-container(width, height, title, t, extra-legend-separation: extra-legend-separation, + box(width: width, height: height, place(left + top, dx: band, box(width: inner-w, height: height, { + draw-x-axis(linear-x-ticks(nt, alo, ahi, x0, x1, t), lay.y1, t, x0: x0, x1: x1, grid-top: lay.y0, title: x-label) + // The zero line is the category axis + place(left + top, line(start: (x-of(0), lay.y0), end: (x-of(0), lay.y1), stroke: t.axis-stroke)) + lay.labels + if y-label != none { + draw-axis-titles(none, y-label, 0pt, (lay.y0 + lay.y1) / 2, t, + origin-x: lay.label-w + lay.gap / 2, y-tick-width: lay.label-w) + } + for (i, val) in values.enumerate() { + let y = row-y(lay, i) + draw-mark(i, y, x-of(0), x-of(val), get-color(t, i), lay.row-h) + if vsize != none { + let after = val >= 0 + end-label(strs.at(i), x-of(val) + if after { reach } else { -reach }, y, if after { "right" } else { "left" }, + vsize, t.text-color, lo: lay.label-w + lay.gap / 2, hi: inner-w, gap: t.label-offset) + } + } + draw-annotations(annotations, x0, lay.y0, lay.plot-w, lay.y1 - lay.y0, alo, ahi, -0.5, n - 0.5, t) + }))), + ) +} diff --git a/packages/preview/primaviz/0.12.0/src/primitives/rows.typ b/packages/preview/primaviz/0.12.0/src/primitives/rows.typ new file mode 100644 index 0000000000..5535688fc4 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/primitives/rows.typ @@ -0,0 +1,374 @@ +// rows.typ - Row-oriented layout: label column, row geometry, value labels and +// a horizontal value axis. +// +// Horizontal charts (dumbbell, diverging, gantt, bullet, and the horizontal +// bar / lollipop charts) all need the same four things: +// +// 1. a column of row labels whose width is *measured*, not guessed; +// 2. an even row grid beside it; +// 3. value labels that sit beyond the marks without running into the row +// labels or off the plot; +// 4. a horizontal value axis (ticks, optional grid, title) that looks like +// the cartesian charts' x axis and thins its labels instead of letting +// them collide. +// +// Everything here works in the local coordinates of a `box(width, height)` +// whose top-left is (0, 0), like the rest of the chart code. Functions that +// measure text must be called inside `context`. + +#import "../theme.typ": * +#import "../util.typ": nice-ticks, format-number +#import "fonts.typ": text-width, text-height, max-text-width, fit-font-size, label-stride, thin-indices, fit-pitch, pin, place-centered +#import "axes.typ": draw-axis-titles, measure-x-tick-height + +// --------------------------------------------------------------------------- +// Value formatting and label reservation +// --------------------------------------------------------------------------- + +/// Formats a data value for a label: integers stay integers, small floats +/// keep enough decimals to stay distinct (0.0042, not 0), large floats do not +/// trail noise. Honours the theme's `number-format`. Content passes through. +/// +/// - v (int, float, content, str): Value +/// - theme (dictionary): Resolved theme +/// -> str, content +#let value-str(v, theme) = { + if type(v) != int and type(v) != float { return v } + let a = calc.abs(v) + let digits = if v == calc.round(v) { 0 } else if a >= 100 { 1 } else if a >= 1 { 2 } else { + calc.min(6, int(calc.ceil(-calc.log(a, base: 10))) + 1) + } + format-number(v, digits: digits, mode: theme.number-format) +} + +/// Room to keep beside the two ends of a plot so that labels sitting just +/// beyond the outermost marks fit, without reserving more than needed. +/// +/// A mark at fraction `f` of the plot (0 = at that end) has `f × plot` of +/// free room beyond it already; only the shortfall is reserved. Because each +/// reserve shrinks the plot the other end's room depends on, the two are +/// solved together. +/// +/// - span (length): Width available for plot plus both reserves +/// - f-lo (float): How far the lowest mark sits from the low end, 0 to 1 of the plot +/// - need-lo (length): Room the label needs beyond that mark (label + gap + marker radius) +/// - f-hi (float): How far the highest mark sits from the high end, 0 to 1 +/// - need-hi (length): Room the label needs beyond that mark +/// - floor (length): Reserve at least this much on either end +/// - cap (float): Reserve at most this fraction of `span` on either end +/// -> dictionary with `lo` and `hi` lengths +#let end-reserves(span, f-lo, need-lo, f-hi, need-hi, floor: 0pt, cap: 0.3) = { + let lo = floor + let hi = floor + for _ in range(4) { + lo = if f-lo >= 0.999 { floor } else { + calc.max(floor, (need-lo - f-lo * (span - hi)) / (1 - f-lo)) + } + hi = if f-hi >= 0.999 { floor } else { + calc.max(floor, (need-hi - f-hi * (span - lo)) / (1 - f-hi)) + } + lo = calc.min(lo, span * cap) + hi = calc.min(hi, span * cap) + } + (lo: lo, hi: hi) +} + +/// Places a value label beyond the end of a mark, centred on the row. +/// +/// The label goes on the requested `side` of `x`. If it would leave +/// `[lo, hi]` it moves inside the mark when the mark is long enough (using +/// `inside-fill` for contrast), and is otherwise nudged back in bounds, so it +/// never leaves the plot. +/// +/// - body (content, str): Label +/// - x (length): End of the mark +/// - y (length): Row centre +/// - side (str): `"right"` or `"left"`: which way from `x` the label sits +/// - size (length): Font size +/// - fill (color): Label colour outside the mark +/// - lo (length): Lowest x the label may reach +/// - hi (length): Highest x the label may reach +/// - gap (length): Space between mark and label +/// - mark-len (none, length): Length of the mark, for the inside fallback +/// - inside-fill (none, color): Label colour inside the mark +/// -> content +#let end-label(body, x, y, side, size, fill, lo: 0pt, hi: 1000pt, gap: 3pt, mark-len: none, inside-fill: none) = { + let w = text-width(body, size) + let out-x = if side == "right" { x + gap } else { x - gap - w } + let fits = out-x >= lo and out-x + w <= hi + let (lx, color) = if fits { (out-x, fill) } else if mark-len != none and inside-fill != none and mark-len >= w + 2 * gap { + (if side == "right" { x - gap - w } else { x + gap }, inside-fill) + } else { + (calc.max(lo, calc.min(hi - w, out-x)), fill) + } + place(left + top, dx: lx, dy: y, pin(text(size: size, fill: color)[#body])) +} + +// --------------------------------------------------------------------------- +// Row layout +// --------------------------------------------------------------------------- + +/// Lays out a column of row labels beside an even grid of rows. +/// +/// The label column is as wide as the widest label *measured* at the label +/// size, never wider than `label-frac` of the chart. A label that is wider +/// shrinks (down to 75% of its size, never under 6pt), then wraps. +/// +/// Dense rows follow one policy, in this order, so a label is never dropped +/// while a smaller one would do: +/// +/// 1. the row pitch is the caller's (`resolve-size(rows: true)` grows the +/// natural height with the row count, so `height: auto` stays legible); +/// 2. the label size shrinks with the pitch (one line of text per row, room +/// for descenders), down to `min-label-size`; +/// 3. only when even that does not fit are labels thinned: spread evenly +/// over all rows, the first and the last always shown (`thin-indices`). +/// +/// Returns a dictionary: +/// - `labels`: content to place at the origin of the chart box, the row labels +/// right-aligned against the column, each centred on its row +/// - `label-w`, `label-size`: what was resolved +/// - `label-stride`: 1 when every row is labelled, else the least number of rows +/// between two labels +/// - `shown`: indices of the rows that are labelled +/// - `x0`, `x1`, `plot-w`: horizontal extent of the plot area (after the label +/// column, the gap and the insets) +/// - `y0`, `y1`, `row-h`: vertical extent of the rows and the height of one row; +/// row `i` is centred on `y0 + (i + 0.5) × row-h` (see `row-y`) +/// +/// - width (length): Chart box width +/// - height (length): Chart box height +/// - labels (array): Row labels +/// - theme (dictionary): Resolved theme +/// - label-frac (float): Widest the label column may get, as a fraction of `width` +/// - label-size (auto, length): Preferred label size (`auto` is the theme's `axis-label-size`) +/// - label-weight (str): Label font weight +/// - gap (auto, length): Space between the label column and the plot (`auto` is `axis-label-gap`) +/// - inset-left (length): Extra room between the gap and the plot (value labels, marker radius) +/// - inset-right (length): Room kept at the right of the plot +/// - pad-top (length): Band reserved above the rows (headers) +/// - pad-bottom (length): Band reserved below the rows (axis) +/// - pitch (auto, length): Fixed row height; `auto` shares `height - pad-top - pad-bottom` among the rows +/// - label-fill (auto, color): Label colour (`auto` is the theme's `text-color`) +/// - min-label-size (length): Smallest label size before labels are thinned out +/// -> dictionary +#let row-layout( + width, + height, + labels, + theme, + label-frac: 0.35, + label-size: auto, + label-weight: "regular", + gap: auto, + inset-left: 0pt, + inset-right: 0pt, + pad-top: 0pt, + pad-bottom: 0pt, + pitch: auto, + label-fill: auto, + min-label-size: 5pt, +) = { + let n = labels.len() + let gap = if gap == auto { theme.axis-label-gap } else { gap } + let base = if label-size == auto { theme.axis-label-size } else { label-size } + let fill = if label-fill == auto { theme.text-color } else { label-fill } + let row-h = if pitch == auto { (height - pad-top - pad-bottom) / calc.max(n, 1) } else { pitch } + + // A label has to fit its row: shrink with the pitch, thin out past the floor + let floor-size = min-label-size + let fit = fit-pitch(row-h, base, floor: floor-size) + let size = fit.size + let stride = fit.stride + let kept = thin-indices(n, stride) + + // Widest shown label, measured; shrink, then wrap + let shown = labels.enumerate().filter(((i, _)) => i in kept).map(((_, l)) => l) + let cap = width * label-frac + let widest = if shown.len() > 0 { shown.fold(shown.first(), (a, b) => if text-width(b, size, weight: label-weight) > text-width(a, size, weight: label-weight) { b } else { a }) } else { "" } + let w = text-width(widest, size, weight: label-weight) + if w > cap and cap > 0pt { + size = fit-font-size(widest, cap, size, min: calc.max(6pt, size * 0.75), weight: label-weight) + w = text-width(widest, size, weight: label-weight) + } + let wraps = w > cap and cap > 0pt + let label-w = if wraps { cap } else { w } + if wraps { + // Two lines have to fit the row too + size = calc.max(floor-size, calc.min(size, row-h * 0.9 / 2.3)) + } + + let x0 = label-w + gap + inset-left + let x1 = width - inset-right + let y0 = pad-top + let y1 = y0 + n * row-h + + let drawn = for (i, l) in labels.enumerate() { + if i not in kept { continue } + place-centered(0pt, y0 + (i + 0.5) * row-h, + text(size: size, weight: label-weight, fill: fill)[#l], width: label-w, align: right) + } + + ( + labels: drawn, + label-w: label-w, + label-size: size, + label-stride: stride, + shown: kept, + gap: gap, + x0: x0, + x1: x1, + plot-w: x1 - x0, + y0: y0, + y1: y1, + row-h: row-h, + ) +} + +/// Vertical centre of row `i` in a layout from `row-layout`. +/// +/// - lay (dictionary): Layout from `row-layout` +/// - i (int): Row index +/// -> length +#let row-y(lay, i) = lay.y0 + (i + 0.5) * lay.row-h + +/// Size for value labels written beside marks in rows `row-h` apart: the +/// theme's value size, shrunk to the row (digits have no descenders, so a row +/// may be as tall as the size), but never under `floor`, the same floor the +/// row labels use. `none` when the rows are so tight that even `floor` digits +/// would touch (the chart then leaves the values out). +/// +/// - theme (dictionary): Resolved theme +/// - row-h (length): Distance between rows +/// - floor (length): Smallest size worth printing +/// -> none, length +#let row-value-size(theme, row-h, floor: 5pt) = { + if row-h < floor * 0.75 { none } else { calc.max(floor, calc.min(theme.value-label-size, row-h)) } +} + +// --------------------------------------------------------------------------- +// Horizontal value axis +// --------------------------------------------------------------------------- + +/// Height an x axis occupies below its baseline: tick labels, and the title +/// when there is one. Uses the same offsets as the cartesian charts (labels at +/// `label-offset`, title at `axis-label-gap + tick-h + axis-title-gap`), so +/// charts reserving this and drawing with `draw-x-axis` line up with them. +/// +/// - theme (dictionary): Resolved theme +/// - title (none, content): Axis title +/// - marks (bool): Whether tick marks are drawn (pushes the labels down) +/// -> length +#let x-axis-extent(theme, title: none, marks: false) = { + let tick-h = measure-x-tick-height((), theme) + let off = if marks { calc.max(theme.label-offset, theme.tick-length + 2pt) } else { theme.label-offset } + if title == none { off + tick-h } else { + let title-gap = theme.at("axis-title-gap", default: theme.axis-label-gap) + theme.axis-label-gap + tick-h + title-gap + measure(text(size: theme.axis-title-size)[#title]).height + } +} + +/// Tick specifications for a linear x axis spanning `lo` to `hi` over +/// `x0` to `x1`: the nice ticks that fall inside the range, with formatted +/// labels. Feed the result to `draw-x-axis`. +/// +/// - nt (dictionary): Result of `nice-ticks` for the axis +/// - lo (int, float): Value at `x0` +/// - hi (int, float): Value at `x1` +/// - x0 (length): Left end of the axis +/// - x1 (length): Right end of the axis +/// - theme (dictionary): Resolved theme +/// -> array of dictionaries with `x`, `label` and `value` +#let linear-x-ticks(nt, lo, hi, x0, x1, theme) = { + let td = theme.at("tick-digits", default: auto) + let digits = if td != auto { td } else { nt.digits } + let span = if hi == lo { 1 } else { hi - lo } + nt.ticks + .filter(v => v >= lo - 1e-9 * span and v <= hi + 1e-9 * span) + .map(v => ( + x: x0 + (v - lo) / span * (x1 - x0), + label: format-number(v, digits: digits, mode: theme.number-format, step: nt.step), + value: v, + )) +} + +/// Widest tick label, measured: charts reserve half of it beside the axis ends. +/// +/// - ticks (array): Ticks from `linear-x-ticks` +/// - theme (dictionary): Resolved theme +/// -> length +#let tick-label-width(ticks, theme) = max-text-width(ticks.map(tk => tk.label), theme.axis-label-size) + +/// Draws a horizontal value axis at `y`: optional grid, baseline, tick marks, +/// tick labels and title. +/// +/// Labels are centred under their ticks and thinned (never rotated) when they +/// would collide: every Nth tick is labelled, counted from the first tick or, +/// when ticks carry a `k`, by that rank, so a mirrored axis keeps the same +/// labels on both sides. +/// +/// - ticks (array): Dictionaries with `x` and `label` (and optionally `k`) +/// - y (length): Baseline +/// - theme (dictionary): Resolved theme +/// - x0 (auto, length): Left end of the baseline (`auto` is the leftmost tick) +/// - x1 (auto, length): Right end of the baseline (`auto` is the rightmost tick) +/// - grid-top (none, length): When set, and the theme shows a grid, vertical +/// grid lines are drawn from here down to the baseline +/// - axis-line (bool): Draw the baseline +/// - marks (bool): Draw small tick marks under the baseline +/// - title (none, content): Axis title, drawn at the cartesian charts' offset +/// - min-gap (length): Clear space wanted between neighbouring labels +/// -> content +#let draw-x-axis( + ticks, + y, + theme, + x0: auto, + x1: auto, + grid-top: none, + axis-line: true, + marks: false, + title: none, + min-gap: 6pt, +) = { + if ticks.len() == 0 and title == none { return } + let ticks = ticks.sorted(key: tk => tk.x) + let xs = ticks.map(tk => tk.x) + let x0 = if x0 == auto { xs.first() } else { x0 } + let x1 = if x1 == auto { xs.last() } else { x1 } + let size = theme.axis-label-size + + if theme.show-grid and grid-top != none { + for tk in ticks { + place(left + top, line(start: (tk.x, grid-top), end: (tk.x, y), stroke: theme.grid-stroke)) + } + } + if axis-line { + place(left + top, line(start: (x0, y), end: (x1, y), stroke: theme.axis-stroke)) + } + if marks { + for tk in ticks { + place(left + top, line(start: (tk.x, y), end: (tk.x, y + theme.tick-length), stroke: theme.axis-stroke)) + } + } + + // Thin the labels to the tightest tick spacing, measured + let wmax = max-text-width(ticks.map(tk => tk.label), size) + let pitch = if xs.len() > 1 { + xs.slice(1).zip(xs.slice(0, -1)).map(((a, b)) => a - b).fold(1000000pt, calc.min) + } else { 1000000pt } + let stride = label-stride(wmax, pitch, gap: min-gap) + let off = if marks { calc.max(theme.label-offset, theme.tick-length + 2pt) } else { theme.label-offset } + for (i, tk) in ticks.enumerate() { + let rank = tk.at("k", default: i) + if calc.rem(rank, stride) != 0 { continue } + let w = text-width(tk.label, size) + place(left + top, dx: tk.x - w / 2, dy: y + off, + text(size: size, fill: theme.text-color)[#tk.label]) + } + + if title != none { + draw-axis-titles(title, none, (x0 + x1) / 2, 0pt, theme, + origin-x: x0, origin-y: y, x-tick-height: measure-x-tick-height((), theme)) + } +} diff --git a/packages/preview/primaviz/0.12.0/src/primitives/sizing.typ b/packages/preview/primaviz/0.12.0/src/primitives/sizing.typ new file mode 100644 index 0000000000..9561b5ad2c --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/primitives/sizing.typ @@ -0,0 +1,98 @@ +// sizing.typ - Sizing and grid layout utilities + +/// Golden ratio for aspect-ratio calculations +#let _phi = 1.618 + +/// Resolves width and height to absolute lengths using the available region size. +/// Call inside `layout(size => ...)`. Handles auto, ratio (100%), relative (100% - 5pt), +/// and absolute (300pt) values. +/// +/// When width or height is `auto`, computes natural dimensions from data count +/// and theme using: `width = n × cell-size + padding`, `height = width / φ`. +/// Pass `n` (data point count) and `theme` to enable auto-sizing. +/// +/// When `container: true` (the default), reserves space for chart-container's +/// inset padding so charts don't overflow their grid cells. Set `container: false` +/// for charts that render into a plain box without chart-container. +/// +/// - width (auto, length, ratio, relative): Width to resolve +/// - height (auto, length, ratio, relative): Height to resolve +/// - size (dictionary): Available region from `layout(size => ...)` +/// - n (none, int): Number of data categories (for auto-sizing) +/// - theme (none, dictionary): Resolved theme (for auto-sizing) +/// - container (bool): Whether to reserve space for chart-container inset +/// - rows (bool): The chart has one row per datum (horizontal bars, dumbbells, +/// funnels...), so its natural size grows with `n` *vertically*: a fixed +/// natural width and a height of `n × row-pitch` plus the axis padding, +/// instead of the column-chart rule above. +/// - row-pitch (auto, length): Natural height of one row when `rows: true` +/// (`auto` is the theme's `cell-size`, and never less than 1.6 × the axis +/// label size, so an auto-height row chart keeps its labels legible) +/// -> dictionary with `width` and `height` keys +#let resolve-size(width, height, size, n: none, theme: none, container: true, rows: false, row-pitch: auto) = { + import "../primitives/container.typ": container-inset + let ci = if theme != none { theme.at("container-inset", default: container-inset) } else { container-inset } + let margin = if container { 2 * ci } else { 0pt } + + // Compute natural width from data count + theme + let natural-w = if rows { 300pt } else if n != none and theme != none { + let cs = theme.at("cell-size", default: 25pt) + let pl = theme.at("axis-padding-left", default: 41pt) + let pr = theme.at("axis-padding-right", default: 16pt) + n * cs + pl + pr + } else { 300pt } + + // Natural height = golden-ratio proportion of width; a row chart instead + // gives every row its pitch, plus room for the axis + let natural-h = if rows and n != none and theme != none { + // Never under a legible pitch: a row has to hold a label at the theme's size + let legible = 1.6 * theme.at("axis-label-size", default: 8pt) + let pitch = calc.max(legible, if row-pitch == auto { theme.at("cell-size", default: 25pt) } else { row-pitch }) + let pt = theme.at("axis-padding-top", default: 16pt) + let pb = theme.at("axis-padding-bottom", default: 25pt) + calc.max(n, 2) * pitch + pt + pb + } else { natural-w / _phi } + + let resolve(val, avail, natural) = { + let resolved = if val == auto { natural } + else if type(val) == length { val } + else if type(val) == ratio { avail * (val / 100%) } + else if type(val) == relative { val.length + avail * (val.ratio / 100%) } + else { val } + // Clamp to available space so charts don't overflow containers. + if type(resolved) == length and type(avail) == length and avail > 0pt { + calc.min(resolved, avail - margin) + } else { + resolved + } + } + (width: resolve(width, size.width, natural-w), height: resolve(height, size.height, natural-h)) +} + +/// Lays out items in a paged grid with automatic pagebreaks. +/// +/// Groups items into pages of `cols * rows`, rendering each page as a grid +/// and inserting `#pagebreak()` between pages. +/// +/// - items (array): Array of content items (charts, blocks, etc.) +/// - cols (int): Number of columns per page +/// - rows (int): Number of rows per page +/// - col-gutter (length): Horizontal gap between columns +/// - row-gutter (length): Vertical gap between rows +/// -> content +#let page-grid(items, cols: 2, rows: 4, col-gutter: 8pt, row-gutter: 4pt) = { + let per-page = cols * rows + let pages = calc.ceil(items.len() / per-page) + for p in array.range(pages) { + if p > 0 { pagebreak() } + let start = p * per-page + let end = calc.min(start + per-page, items.len()) + let page-items = items.slice(start, end) + grid( + columns: array.range(cols).map(_ => 1fr), + column-gutter: col-gutter, + row-gutter: row-gutter, + ..page-items, + ) + } +} diff --git a/packages/preview/primaviz/0.12.0/src/primitives/status.typ b/packages/preview/primaviz/0.12.0/src/primitives/status.typ new file mode 100644 index 0000000000..491dfaa060 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/primitives/status.typ @@ -0,0 +1,19 @@ +// status.typ - Semantic colours shared by the dashboard pieces + +#import "../theme.typ": _is-dark + +/// Good / bad / caution colours for metric deltas and alerts, light or dark. +/// A theme can override them with `positive-color`, `negative-color` and +/// `warning-color`; `info` is the theme's first palette colour. +/// +/// - t (dictionary): Resolved theme +/// -> dictionary with `positive`, `negative`, `warning`, `info` +#let status-colors(t) = { + let dark = _is-dark(t) + ( + positive: if "positive-color" in t { t.positive-color } else if dark { rgb("#4ade80") } else { rgb("#16a34a") }, + negative: if "negative-color" in t { t.negative-color } else if dark { rgb("#f87171") } else { rgb("#dc2626") }, + warning: if "warning-color" in t { t.warning-color } else if dark { rgb("#fbbf24") } else { rgb("#d97706") }, + info: t.palette.at(0), + ) +} diff --git a/packages/preview/primaviz/0.12.0/src/primitives/title.typ b/packages/preview/primaviz/0.12.0/src/primitives/title.typ new file mode 100644 index 0000000000..1ffbf4b3c4 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/primitives/title.typ @@ -0,0 +1,16 @@ +// title.typ - Title rendering primitive + +#import "../theme.typ": * + +#let draw-title(title, theme, subtitle: none) = { + if title == none and subtitle == none { return } + v(2pt) + if title != none { + align(center, text(size: theme.title-size, weight: theme.title-weight, fill: theme.text-color)[#title]) + } + if subtitle != none { + if title != none { v(1pt) } + align(center, text(size: theme.at("subtitle-size", default: theme.axis-title-size), fill: theme.text-color-light)[#subtitle]) + } + v(theme.title-gap) +} diff --git a/packages/preview/primaviz/0.12.0/src/theme.typ b/packages/preview/primaviz/0.12.0/src/theme.typ new file mode 100644 index 0000000000..979638e3b5 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/theme.typ @@ -0,0 +1,348 @@ +// theme.typ - Theme system for primaviz +// +// Golden-ratio proportional scaling: two seed values (base-size, base-gap) +// drive all font sizes and spacing through φ-powers. Users can override +// any individual property, or change the seeds to scale everything at once. + +// Global theme state — set via with-theme(), read by chart functions +// Stack of active `with-theme` themes, innermost last +#let _primaviz-theme = state("primaviz-theme", ()) + +// Golden ratio constant +#let _phi = 1.618 +#let _sqrt-phi = calc.sqrt(_phi) + +/// Derives a complete theme from two seed values using golden-ratio scaling. +/// +/// Font scale (base-size × φ^power): +/// φ^0 = 1.00× axis-label-size +/// φ^0.5 = 1.27× axis-title-size, value-label-size, legend-size +/// φ^1 = 1.62× title-size +/// +/// Spacing scale (base-gap × φ^power): +/// φ^-1 = 0.62× bar-gap, cell-gap +/// φ^-0.5 = 0.79× tick-length, label-offset +/// φ^0 = 1.00× axis-label-gap, axis-title-gap +/// φ^0.5 = 1.27× title-gap, container-inset +/// φ^1 = 1.62× legend-gap +/// φ^1.5 = 2.06× legend-swatch-size +/// φ^2 = 2.62× axis-padding-top, axis-padding-right +/// φ^3 = 4.24× axis-padding-bottom +/// φ^4 = 6.85× axis-padding-left +/// +/// Stroke scale (base-size × ratio): +/// ×0.0625 stroke-thin (0.5pt @ 8pt) — axes, grid, markers +/// ×0.125 stroke-mid (1.0pt @ 8pt) — box plots, stacked-area boundaries +/// ×0.1875 stroke-thick (1.5pt @ 8pt) — lines, stems, slopes, dumbbells +/// ×0.3125 stroke-heavy (2.5pt @ 8pt) — bump lines +#let _derive-theme(base-size, base-gap) = { + let phi2 = _phi * _phi + let phi3 = phi2 * _phi + let phi4 = phi3 * _phi + let inv-phi = 1.0 / _phi + let inv-sqrt-phi = 1.0 / _sqrt-phi + ( + // Seeds (and a record of them, so layering can tell derived sizes + // from deliberate overrides — see _layer) + base-size: base-size, + base-gap: base-gap, + _derived-from: (base-size: base-size, base-gap: base-gap), + + // Palette + palette: ( + rgb("#4e79a7"), rgb("#f28e2b"), rgb("#e15759"), rgb("#76b7b2"), + rgb("#59a14f"), rgb("#edc948"), rgb("#b07aa1"), rgb("#ff9da7"), + rgb("#9c755f"), rgb("#bab0ac"), + ), + + // Font scale — golden ratio from base-size + axis-label-size: base-size, + axis-title-size: base-size * _sqrt-phi, + value-label-size: base-size * _sqrt-phi, + legend-size: base-size * _sqrt-phi, + title-size: base-size * _phi, + title-weight: "bold", + + // Spacing scale — golden ratio from base-gap + bar-gap: base-gap * inv-phi, // gap between grouped bars + cell-gap: base-gap * inv-phi, // heatmap/waffle cell gap + cell-size: base-gap * phi3, // heatmap cell size (≈25pt @ 6pt) + tick-length: base-gap * inv-sqrt-phi, // axis tick mark length + label-offset: base-gap * inv-sqrt-phi, // label-to-element spacing + axis-label-gap: base-gap, // gap between axis and tick labels + axis-title-gap: base-gap, // gap between tick labels and axis title + container-inset: base-gap * _sqrt-phi, // chart container padding + title-gap: base-gap * _sqrt-phi, + legend-gap: base-gap * _phi, + legend-swatch-size: base-gap * _phi * _sqrt-phi, + axis-padding-top: base-gap * phi2, + axis-padding-right: base-gap * phi2, + axis-padding-bottom: base-gap * phi3, + axis-padding-left: base-gap * phi4, + + // Stroke scale — proportional to base-size + stroke-thin: base-size * 0.0625, // 0.5pt @ 8pt + stroke-mid: base-size * 0.125, // 1.0pt @ 8pt + stroke-thick: base-size * 0.1875, // 1.5pt @ 8pt + stroke-heavy: base-size * 0.3125, // 2.5pt @ 8pt + + // Non-scaled properties + tick-count: 5, + tick-digits: auto, // auto-detect from step size, or set to int for explicit control + number-format: "auto", + axis-stroke: 0.5pt + black, + grid-stroke: 0.5pt + luma(230), + show-grid: false, + legend-position: "bottom", + marker-stroke: 0.5pt + white, + text-color: black, + text-color-light: luma(130), + text-color-inverse: white, + background: none, + border: none, + border-radius: base-gap, + rotated-threshold: 8, // number of x-axis labels before auto-rotating to save space + ) +} + +// Default theme — derived from base seeds +#let default-theme = _derive-theme(8pt, 6pt) + +// Keys whose values are derived from seeds — used to detect explicit overrides +#let _seed-derived-keys = ( + "axis-label-size", "axis-title-size", "value-label-size", "legend-size", "title-size", + "axis-label-gap", "axis-title-gap", "title-gap", "legend-gap", "legend-swatch-size", + "axis-padding-top", "axis-padding-right", "axis-padding-bottom", "axis-padding-left", + "bar-gap", "cell-gap", "cell-size", "tick-length", "label-offset", "container-inset", + "stroke-thin", "stroke-mid", "stroke-thick", "stroke-heavy", + "border-radius", +) + +// Final seeds: the last layer that sets base-size / base-gap wins. +#let _seeds(..layers) = { + let bs = default-theme.base-size + let bg = default-theme.base-gap + for layer in layers.pos() { + if layer != none { + bs = layer.at("base-size", default: bs) + bg = layer.at("base-gap", default: bg) + } + } + (bs, bg) +} + +// Layers a theme dictionary onto `result`. A derived theme (a preset, or +// anything from resolve-theme / _derive-theme) records the seeds its sizes +// came from in `_derived-from`; its seed-derived values only count as +// overrides where they differ from what those seeds produce, so the rest +// rescale with the final seeds — `(..themes.presentation, base-size: 12pt)` +// scales everything to 12pt. In a plain dictionary every key is explicit. +#let _layer(result, layer) = { + if layer == none { return result } + let from = layer.at("_derived-from", default: none) + let own = if from != none { _derive-theme(from.base-size, from.base-gap) } + for (key, val) in layer { + if key in ("base-size", "base-gap", "_derived-from") { continue } + if own != none and key in _seed-derived-keys and val == own.at(key) { continue } + result.insert(key, val) + } + result +} + +/// Merges a user's partial theme dictionary onto the default theme. +/// Sizes are derived from the theme's `base-size` / `base-gap` seeds. +/// Custom keys not in default-theme are preserved (passthrough). +/// An optional `overrides` dictionary is applied after the user theme, +/// useful for per-chart parameter overrides (e.g., `show-grid: true`). +/// +/// - user-theme (none, dictionary): Partial theme overrides; `none` returns the default theme +/// - overrides (none, dictionary): Additional per-call overrides applied last +/// -> dictionary +#let resolve-theme(user-theme, overrides: none) = { + let (bs, bg) = _seeds(user-theme, overrides) + _layer(_layer(_derive-theme(bs, bg), user-theme), overrides) +} + +/// Builds a primaviz theme dictionary from a JSON tokens object. +/// Expects keys: `palette` (array of hex strings), `text-color`, `text-color-light`, +/// `text-color-inverse`, `background` (hex or null), `border-color`, `border-radius` (number in pt). +/// Unknown keys are preserved as custom passthrough keys. +/// +/// - tokens (dictionary): Parsed JSON tokens object +/// -> dictionary +#let theme-from-json(tokens) = { + let pal = tokens.at("palette", default: ()).map(c => rgb(c)) + let bg = tokens.at("background", default: none) + let bg = if bg != none and type(bg) == str { rgb(bg) } else { none } + let border-hex = tokens.at("border-color", default: "#ddd") + let radius = tokens.at("border-radius", default: 4) + + let theme = ( + palette: if pal.len() > 0 { pal } else { default-theme.palette }, + text-color: if "text-color" in tokens { rgb(tokens.text-color) } else { default-theme.text-color }, + text-color-light: if "text-color-light" in tokens { rgb(tokens.at("text-color-light")) } else { default-theme.text-color-light }, + text-color-inverse: if "text-color-inverse" in tokens { rgb(tokens.at("text-color-inverse")) } else { default-theme.text-color-inverse }, + background: bg, + border: 0.75pt + rgb(border-hex), + axis-stroke: 0.5pt + rgb(border-hex), + grid-stroke: 0.3pt + rgb(border-hex), + show-grid: true, + border-radius: radius * 1pt, + ) + + // Passthrough: preserve any extra keys from tokens + for (key, val) in tokens { + if key not in theme and key != "palette" and key != "border-color" and key != "border-radius" { + theme.insert(key, val) + } + } + + resolve-theme(theme) +} + +/// Context-aware theme resolver — reads from state when user-theme is none. +/// When user-theme is provided, it merges onto the global state (set via +/// `with-theme`) rather than replacing it, so partial overrides like +/// `theme: (show-grid: true)` inside a `with-theme` block work correctly. +/// +/// If `base-size` or `base-gap` are set, all φ-derived properties are +/// recomputed from the new seeds — unless individually overridden. +/// +/// Custom keys not in default-theme are preserved (passthrough). +/// Must be called inside a `context` block. +/// +/// - user-theme (none, dictionary): Explicit theme overrides (merged onto global state) +/// - overrides (none, dictionary): Additional per-call overrides +/// -> dictionary +#let _resolve-ctx(user-theme, overrides: none) = { + let stack = _primaviz-theme.get() + let global = if stack.len() > 0 { stack.last() } else { none } + let (bs, bg) = _seeds(global, user-theme, overrides) + let result = _derive-theme(bs, bg) + for layer in (global, user-theme, overrides) { + result = _layer(result, layer) + } + result +} + +// Whether a theme draws on a dark surface. Judged by perceived lightness, +// not by `background` merely being set: a custom white background is light. +#let _is-dark(theme) = ( + theme.background != none and oklab(theme.background).components().first() < 50% +) + +// A surface tone set off from the theme background (tracks, empty cells, +// zebra rows): lighter on dark themes, darker on light ones. +#let _shade(theme, amount) = if _is-dark(theme) { + theme.background.lighten(amount) +} else { + theme.background.darken(amount / 2) +} + +/// Sets a default theme for all charts in `body`. +/// +/// Charts inside `body` that don't pass an explicit `theme:` parameter +/// will use this theme instead of `themes.default`. +/// +/// ```typst +/// #with-theme(themes.dark)[ +/// #bar-chart(data) // uses dark theme +/// #pie-chart(data2) // uses dark theme +/// #line-chart(data3, theme: themes.minimal) // explicit override wins +/// ] +/// ``` +/// +/// Can also be used as a show rule for the entire document: +/// ```typst +/// #show: with-theme.with(themes.dark) +/// ``` +/// +/// - theme (dictionary): Theme to use as default +/// - body (content): Content whose charts inherit this theme +/// -> content +#let with-theme(theme, body) = { + // Push/pop rather than reset, so nesting restores the outer theme + _primaviz-theme.update(s => s + (theme,)) + body + _primaviz-theme.update(s => s.slice(0, -1)) +} + +/// Returns a color from the theme palette, cycling if the index exceeds palette length. +/// +/// - theme (dictionary): Resolved theme dictionary +/// - index (int): Color index (wraps around via modulo) +/// -> color +#let get-color(theme, index) = { + let pal = theme.palette + pal.at(calc.rem(index, pal.len())) +} + +// --- Named preset themes --- + +// Preset themes — each is a partial override dict resolved against default-theme. +// Usage: `chart(data, theme: themes.minimal)` or `chart(data, theme: themes.dark)` + +#let minimal-theme = resolve-theme(( + palette: ( + rgb("#7a9ec2"), rgb("#d4a76a"), rgb("#c27a7c"), rgb("#8ebdb9"), + rgb("#7fb07a"), rgb("#d4c36e"), rgb("#b99cb2"), rgb("#e0b3b8"), + rgb("#b09a89"), rgb("#c5beb9"), + ), + axis-stroke: 0.3pt + luma(150), + grid-stroke: 0.3pt + luma(240), + show-grid: true, + title-weight: "regular", + border: none, +)) + +#let dark-theme = resolve-theme(( + background: rgb("#1a1a2e"), + palette: ( + rgb("#00d2ff"), rgb("#ff6b6b"), rgb("#c56cf0"), rgb("#ffdd59"), rgb("#0be881"), + rgb("#ff9f43"), rgb("#48dbfb"), rgb("#ff6348"), rgb("#1dd1a1"), rgb("#f368e0"), + ), + axis-stroke: 0.5pt + rgb("#cccccc"), + grid-stroke: 0.5pt + rgb("#333355"), + marker-stroke: 0.5pt + rgb("#1a1a2e"), + text-color: rgb("#e0e0e0"), + text-color-light: rgb("#888899"), + text-color-inverse: rgb("#1a1a2e"), +)) + +#let presentation-theme = resolve-theme(( + base-size: 10pt, + base-gap: 5pt, +)) + +#let print-theme = resolve-theme(( + palette: ( + luma(40), luma(90), luma(140), luma(180), luma(60), + luma(110), luma(160), luma(200), luma(80), luma(130), + ), + show-grid: true, + grid-stroke: 0.4pt + luma(200), +)) + +#let accessible-theme = resolve-theme(( + palette: ( + rgb("#E69F00"), rgb("#56B4E9"), rgb("#009E73"), rgb("#F0E442"), + rgb("#0072B2"), rgb("#D55E00"), rgb("#CC79A7"), rgb("#000000"), + ), +)) + +#let compact-theme = resolve-theme(( + base-size: 5pt, + base-gap: 3pt, + show-grid: true, + grid-stroke: 0.3pt + luma(230), +)) + +#let themes = ( + default: default-theme, + minimal: minimal-theme, + dark: dark-theme, + presentation: presentation-theme, + print: print-theme, + accessible: accessible-theme, + compact: compact-theme, +) diff --git a/packages/preview/primaviz/0.12.0/src/util.typ b/packages/preview/primaviz/0.12.0/src/util.typ new file mode 100644 index 0000000000..7d56ae4dea --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/util.typ @@ -0,0 +1,464 @@ +// util.typ - Shared utilities for primaviz + +// sRGB channel (0..1) to linear light +#let _lin(c) = if c <= 0.04045 { c / 12.92 } else { calc.pow((c + 0.055) / 1.055, 2.4) } + +/// WCAG relative luminance of a colour (0 = black, 1 = white). +/// +/// - c (color): Colour (alpha is ignored) +/// -> float +#let luminance(c) = { + let k = rgb(c).components() + 0.2126 * _lin(k.at(0) / 100%) + 0.7152 * _lin(k.at(1) / 100%) + 0.0722 * _lin(k.at(2) / 100%) +} + +/// WCAG contrast ratio between two colours (1 = identical, 21 = black on white). +/// +/// - a (color): First colour +/// - b (color): Second colour +/// -> float +#let contrast-ratio(a, b) = { + let (la, lb) = (luminance(a), luminance(b)) + (calc.max(la, lb) + 0.05) / (calc.min(la, lb) + 0.05) +} + +/// Text colour for a label drawn on a filled mark, chosen by WCAG contrast +/// ratio rather than a luma guess (white on `#e15759` is 3.6:1, black 5.8:1). +/// +/// Without a `theme` the answer is black or white, whichever has the higher +/// contrast ratio against `c`. With a `theme` its own text colours are tried +/// first, darker first, and the first with at least `min-ratio` contrast +/// against the fill wins, so labels keep the theme's look (a dark theme's +/// soft white, say); if neither reaches the ratio, black or white, whichever +/// is stronger. +/// +/// - c (color): The mark's fill +/// - theme (none, dictionary): Resolved theme, to prefer its text colours +/// - min-ratio (float): Contrast ratio worth keeping a theme colour for +/// -> color +#let contrast-text(c, theme: none, min-ratio: 4.5) = { + let strongest = if contrast-ratio(black, c) >= contrast-ratio(white, c) { black } else { white } + if theme == none { return strongest } + let theirs = (theme.text-color-inverse, theme.text-color).sorted(key: k => luminance(k)) + let ok = theirs.find(k => contrast-ratio(k, c) >= min-ratio) + if ok != none { ok } else { strongest } +} + +/// Normalizes data input to a `(labels: array, values: array)` dictionary. +/// +/// - data (dictionary, array): Dict with `labels`/`values` keys, or array of `(label, value)` tuples +/// -> dictionary +#let normalize-data(data) = { + let labels = if type(data) == dictionary { data.labels } else { data.map(d => d.at(0)) } + let values = if type(data) == dictionary { data.values } else { data.map(d => d.at(1)) } + (labels: labels, values: values) +} + +/// Linearly interpolates between two colors. +/// +/// - c1 (color): Start color (at t=0) +/// - c2 (color): End color (at t=1) +/// - t (float): Interpolation factor, clamped to 0..1 +/// -> color +#let lerp-color(c1, c2, t) = { + let t-clamped = calc.max(0, calc.min(1, t)) + color.mix((c1, (1 - t-clamped) * 100%), (c2, t-clamped * 100%)) +} + +// Named palette definitions — arrays of color stops for N-stop interpolation. +#let _named-palettes = ( + viridis: (rgb("#440154"), rgb("#3b528b"), rgb("#21918c"), rgb("#5ec962"), rgb("#fde725")), + // Ordered, light to dark (ColorBrewer YlOrRd): more is always darker, so it + // reads as an amount. It used to run blue-green-red, a rainbow. + heat: (rgb("#ffffb2"), rgb("#fecc5c"), rgb("#fd8d3c"), rgb("#f03b20"), rgb("#bd0026")), + blues: (rgb("#f7fbff"), rgb("#6baed6"), rgb("#08306b")), + greens: (rgb("#f7fcf5"), rgb("#74c476"), rgb("#00441b")), + reds: (rgb("#fff5f0"), rgb("#fb6a4a"), rgb("#67000d")), + purples: (rgb("#fcfbfd"), rgb("#9e9ac8"), rgb("#3f007d")), + inferno: (rgb("#000004"), rgb("#420a68"), rgb("#932667"), rgb("#dd513a"), rgb("#fcffa4")), + plasma: (rgb("#0d0887"), rgb("#7e03a8"), rgb("#cc4778"), rgb("#f89540"), rgb("#f0f921")), + coolwarm: (rgb("#3b4cc0"), rgb("#7b9ff9"), rgb("#f7f7f7"), rgb("#f4987a"), rgb("#b40426")), + spectral: (rgb("#9e0142"), rgb("#f46d43"), rgb("#fee08b"), rgb("#abdda4"), rgb("#5e4fa2")), +) + +/// Interpolates across an array of color stops for a 0..1 value. +/// +/// - stops (array): Array of colors (2 or more) +/// - v (float): Normalized value 0..1 +/// -> color +#let _interpolate-stops(stops, v) = { + let n = stops.len() + if n == 1 { return stops.at(0) } + let scaled = v * (n - 1) + let idx = calc.min(int(scaled), n - 2) + let t = scaled - idx + lerp-color(stops.at(idx), stops.at(idx + 1), t) +} + +/// Maps a normalized 0..1 value to a color using a palette. +/// +/// Palette can be a string name (`"viridis"`, `"heat"`, `"blues"`, `"greens"`, +/// `"reds"`, `"purples"`, `"inferno"`, `"plasma"`, `"coolwarm"`, `"spectral"`, +/// `"grayscale"`) or an array of color stops for custom palettes. +/// +/// Append `"-r"` to any named palette to reverse it (e.g., `"viridis-r"`). +/// +/// - val (float): Value in the range 0 to 1 +/// - palette (str, array): Palette name or array of color stops +/// - reverse (bool): Reverse the palette direction +/// -> color +#let heat-color(val, palette: "viridis", reverse: false) = { + let v = calc.max(0, calc.min(1, val)) + + // Handle array palette directly + if type(palette) == array { + let stops = if reverse { palette.rev() } else { palette } + return _interpolate-stops(stops, v) + } + + // Handle grayscale specially (not stop-based) + if palette == "grayscale" or palette == "grayscale-r" { + let rv = if reverse or palette == "grayscale-r" { v } else { 1 - v } + return luma(int(rv * 255)) + } + + // Check for -r suffix + let pal-name = palette + let rev = reverse + if palette.ends-with("-r") { + pal-name = palette.slice(0, palette.len() - 2) + rev = true + } + + // Look up named palette + let stops = _named-palettes.at(pal-name, default: _named-palettes.at("blues")) + if rev { stops = stops.rev() } + _interpolate-stops(stops, v) +} + +/// Returns the value unchanged if non-zero, otherwise returns `fallback`. +/// Prevents division-by-zero in range calculations. +/// +/// - val (int, float): Value to check +/// - fallback (int, float): Returned when `val` is zero +/// -> int, float +#let nonzero(val, fallback: 1) = if val == 0 { fallback } else { val } + +/// Extracts the plain text of a label that may be a string, a number, or content. +/// +/// Labels accept arbitrary content (`$x^2$`, `[*Q1*]`), which has no `.len()` and +/// cannot be concatenated with strings. Layout heuristics and error messages need a +/// plain-text form, so walk the content tree and gather every text leaf. The result +/// is an approximation for typeset math — good enough to size labels, never used for +/// rendering (charts always place the original value). +/// +/// - v (any): Label value — string, number, or content +/// -> str +#let label-str(v) = { + if type(v) == str { + v + } else if type(v) == int or type(v) == float { + str(v) + } else if type(v) == array { + v.map(label-str).fold("", (a, b) => a + b) + } else if type(v) == content { + if v.has("text") and type(v.text) == str { + v.text + } else { + let out = "" + for (_, field) in v.fields() { + if type(field) == str or type(field) == content or type(field) == array { + out += label-str(field) + } + } + out + } + } else { + "" + } +} + +/// Returns the character count of a label for width heuristics. +/// +/// Accepts the same values as `label-str`; content labels are measured by their +/// extracted text. +/// +/// - v (any): Label value — string, number, or content +/// -> int +#let label-len(v) = label-str(v).clusters().len() + +/// Clamps a numeric value to the range [lo, hi]. +/// +/// - val (int, float): Value to clamp +/// - lo (int, float): Lower bound +/// - hi (int, float): Upper bound +/// -> int, float +#let clamp(val, lo, hi) = { + calc.max(lo, calc.min(hi, val)) +} + +/// Formats a number for display using the specified mode. +/// +/// - val (int, float): Number to format +/// - digits (int): Decimal places to round to +/// - mode (str): Format mode: `"auto"`, `"comma"`, `"si"`, `"plain"`, or `"percent"` +/// - step (none, int, float): Tick step, when formatting axis ticks. In SI +/// mode it sets the decimals so adjacent ticks stay distinct (10.05k, not +/// two "10.1k"); without it SI values get one decimal. +/// -> str +#let format-number(val, digits: 1, mode: "auto", step: none) = { + let abs-val = calc.abs(val) + let rounded = calc.round(val, digits: digits) + + if mode == "si" or (mode == "auto" and abs-val >= 10000) { + // SI abbreviations, largest tier first + let tiers = ((1000000000, "B"), (1000000, "M"), (1000, "k")) + let pick = tiers.position(((scale, _)) => abs-val >= scale) + let decimals(scale) = if step != none and step > 0 { + int(calc.max(0, -calc.floor(calc.log(step / scale, base: 10)))) + } else { 1 } + if pick == none { + str(rounded) + } else { + let (scale, suffix) = tiers.at(pick) + let scaled = calc.round(val / scale, digits: decimals(scale)) + // Rounding can carry into the next tier (999,999.9 -> "1000k" -> "1M") + if calc.abs(scaled) >= 1000 and pick > 0 { + let (scale, suffix) = tiers.at(pick - 1) + str(calc.round(val / scale, digits: decimals(scale))) + suffix + } else { + str(scaled) + suffix + } + } + } else if mode == "comma" { + // Add comma separators - Typst doesn't have built-in number formatting + // so we'll build the string manually + let s = str(calc.round(val, digits: digits)) + let negative = s.starts-with("-") + let abs-str = if negative { s.slice(1) } else { s } + let parts = abs-str.split(".") + let int-str = parts.at(0) + let result = "" + let count = 0 + let chars = int-str.clusters() + let n = chars.len() + for i in array.range(n) { + let idx = n - 1 - i + if count > 0 and calc.rem(count, 3) == 0 { + result = "," + result + } + result = chars.at(idx) + result + count = count + 1 + } + if parts.len() > 1 { + result = result + "." + parts.at(1) + } + if negative { "-" + result } else { result } + } else if mode == "percent" { + str(calc.round(val, digits: digits)) + "%" + } else { + // plain or auto (below threshold) + str(rounded) + } +} + +/// Sorts a simple data dictionary by values. +/// +/// - data (dictionary): Dict with `labels` and `values` arrays +/// - descending (bool): Sort in descending order +/// -> dictionary +#let sort-data(data, descending: false) = { + let pairs = data.labels.zip(data.values) + let sorted = pairs.sorted(key: p => p.at(1)) + if descending { + sorted = sorted.rev() + } + (labels: sorted.map(p => p.at(0)), values: sorted.map(p => p.at(1))) +} + +/// Returns the top N entries by value (descending) from a simple data dictionary. +/// +/// - data (dictionary): Dict with `labels` and `values` arrays +/// - n (int): Number of top entries to return +/// -> dictionary +#let top-n(data, n) = { + let sorted = sort-data(data, descending: true) + (labels: sorted.labels.slice(0, calc.min(n, sorted.labels.len())), + values: sorted.values.slice(0, calc.min(n, sorted.values.len()))) +} + +/// Aggregates multi-series data into a simple label-value dictionary. +/// +/// - data (dictionary): Dict with `labels` and `series` (each with `values`) +/// - fn (str): Aggregation function: `"sum"`, `"mean"`, `"max"`, or `"min"` +/// -> dictionary +#let aggregate(data, fn: "sum") = { + let n = data.labels.len() + let agg-values = () + for i in array.range(n) { + let vals = data.series.map(s => s.values.at(i)) + let result = if fn == "sum" { vals.sum() } + else if fn == "mean" { vals.sum() / vals.len() } + else if fn == "max" { calc.max(..vals) } + else if fn == "min" { calc.min(..vals) } + else { vals.sum() } + agg-values.push(result) + } + (labels: data.labels, values: agg-values) +} + +// Nice fractions of a power of ten, as used by nice-ceil / nice-floor. +#let _nice-fracs = (1, 1.5, 2, 2.5, 3, 4, 5, 7, 10) + +// Rounds a positive value to a nice number, up or down. The fraction is +// rounded before comparing and the result snapped, so float noise neither +// bumps a value to the next step nor leaks out (1.5 × 0.1 = 0.15000000000000002). +#let _nice-positive(val, up) = { + let exp = calc.floor(calc.log(val, base: 10)) + let base = calc.pow(10, exp) + let frac = calc.round(val / base, digits: 9) + let nice = if up { + _nice-fracs.find(f => frac <= f) + } else { + _nice-fracs.rev().find(f => frac >= f) + } + let x = nice * base + if exp < 0 { calc.round(x, digits: -exp + 2) } else { x } +} + +/// Rounds a value up to the next "nice" number for axis scaling. +/// Uses the standard nice-number algorithm (similar to D3/matplotlib). +/// +/// - val (int, float): Value to round up +/// -> int, float +#let nice-ceil(val) = { + if val == 0 { 0 } + else if val > 0 { _nice-positive(val, true) } + else { -_nice-positive(-val, false) } +} + +/// Rounds a value down to the previous "nice" number for axis scaling. +/// +/// - val (int, float): Value to round down +/// -> int, float +#let nice-floor(val) = { + if val == 0 { 0 } + else if val > 0 { _nice-positive(val, false) } + else { -_nice-positive(-val, true) } +} + +/// Normalizes an `errors` parameter for bar/line/scatter charts into a uniform +/// list of `(low, high)` dicts. Accepts either symmetric (single number per +/// point) or asymmetric (two-tuple per point) specifications. +/// +/// - errors (none, array): Symmetric `(e1, e2, ...)` or asymmetric `((lo1, hi1), ...)` +/// - n (int): Expected number of entries +/// -> none, array +#let normalize-errors(errors, n) = { + if errors == none { return none } + assert(errors.len() == n, + message: "errors length (" + str(errors.len()) + ") must match data length (" + str(n) + ")") + let result = () + for e in errors { + if type(e) == array { + result.push((low: e.at(0), high: e.at(1))) + } else { + result.push((low: e, high: e)) + } + } + result +} + +/// Computes nice step-aligned tick values for an axis using D3-style step selection. +/// Steps are restricted to 1, 2, 5, or 10 × 10^n for clean tick labels. +/// Returns a dictionary with `min`, `max`, `step`, `ticks` (array of values), +/// and `digits` (auto-detected decimal places for formatting). +/// +/// - data-min (number): Minimum data value +/// - data-max (number): Maximum data value +/// - count (int): Target number of ticks (actual count may vary slightly) +/// - step (auto, number): Use this step instead of picking one from `count` (the ticks +/// still snap outward to multiples of it) +/// -> dictionary +#let nice-ticks(data-min, data-max, count: 5, step: auto) = { + // A flat series (all values equal) would snap to a single tick; give it a + // band around the value so the axis still has a scale. + if data-min == data-max { + let pad = if data-min == 0 { 1 } else { calc.abs(data-min) * 0.1 } + data-min -= pad + data-max += pad + } + let raw-range = data-max - data-min + // Target intervals = count - 1 + let raw-step = raw-range / calc.max(1, count - 1) + + // Round step to a nice number: 1, 2, 5, or 10 × 10^n (D3 algorithm) + let exp = calc.floor(calc.log(raw-step, base: 10)) + let base = calc.pow(10, exp) + let error = raw-step / base + // D3 thresholds: sqrt(50), sqrt(10), sqrt(2) partition [1, 10) into step tiers + let step = if step != auto { step } + else if error >= calc.sqrt(50) { base * 10 } + else if error >= calc.sqrt(10) { base * 5 } + else if error >= calc.sqrt(2) { base * 2 } + else { base } + + // Snap min/max to step boundaries + // (the epsilon keeps 0.6 / 0.2 = 2.9999999999999996 from snapping a whole + // step past the data) + let tick-min = calc.round(calc.floor(data-min / step + 1e-9) * step, digits: 10) + let tick-max = calc.round(calc.ceil(data-max / step - 1e-9) * step, digits: 10) + + // Generate ticks + let ticks = () + let v = tick-min + while v <= tick-max + step * 0.001 { + let rv = calc.round(v, digits: 10) + ticks.push(if rv == calc.floor(rv) { int(rv) } else { rv }) + v += step + } + + // Auto-detect digits from step size (Heckbert's TickmarkPrecision formula) + let digits = int(calc.max(-calc.floor(calc.log(step, base: 10)), 0)) + + (min: tick-min, max: tick-max, step: step, ticks: ticks, digits: digits) +} + +/// Computes nice-rounded (min, max, range) for a numeric array. +/// Returns a dictionary with `min`, `max`, and `range` (nonzero-guarded) keys. +/// +/// - vals (array): Array of numbers +/// -> dictionary +#let numeric-range(vals) = { + let lo = nice-floor(calc.min(..vals)) + let hi = nice-ceil(calc.max(..vals)) + (min: lo, max: hi, range: nonzero(hi - lo)) +} + +/// Returns day-of-week for a "YYYY-MM-DD" string (0=Mon … 6=Sun). +/// +/// Uses Tomohiko Sakamoto's algorithm. +/// +/// - date-str (str): Date in "YYYY-MM-DD" format +/// -> int +#let day-of-week(date-str) = { + let parts = date-str.split("-") + let y = int(parts.at(0)) + let m = int(parts.at(1)) + let d = int(parts.at(2)) + let offsets = (0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4) + let yy = if m < 3 { y - 1 } else { y } + let dow = calc.rem(yy + calc.div-euclid(yy, 4) - calc.div-euclid(yy, 100) + calc.div-euclid(yy, 400) + offsets.at(m - 1) + d, 7) + // Convert from 0=Sun to 0=Mon: Sun(0)→6, Mon(1)→0, …, Sat(6)→5 + calc.rem(dow + 6, 7) +} + +/// Converts values to percentages of total, returning a new data dictionary. +/// +/// - data (dictionary): Dict with `labels` and `values` arrays +/// -> dictionary +#let percent-of-total(data) = { + let total = data.values.sum() + if total == 0 { return data } + (labels: data.labels, values: data.values.map(v => calc.round(v / total * 100, digits: 1))) +} diff --git a/packages/preview/primaviz/0.12.0/src/validate.typ b/packages/preview/primaviz/0.12.0/src/validate.typ new file mode 100644 index 0000000000..4c5b83f6f6 --- /dev/null +++ b/packages/preview/primaviz/0.12.0/src/validate.typ @@ -0,0 +1,418 @@ +// validate.typ - Input validation helpers for chart functions +#import "util.typ": label-str + +// Validate simple data (labels + values) +#let validate-simple-data(data, chart-name) = { + assert(type(data) == dictionary or type(data) == array, + message: chart-name + ": data must be a dictionary or array") + if type(data) == dictionary { + assert("labels" in data, message: chart-name + ": data must have 'labels' key") + assert("values" in data, message: chart-name + ": data must have 'values' key") + assert(data.labels.len() > 0, message: chart-name + ": labels must not be empty") + assert(data.labels.len() == data.values.len(), + message: chart-name + ": labels (" + str(data.labels.len()) + ") and values (" + str(data.values.len()) + ") must have same length") + } + if type(data) == array { + assert(data.len() > 0, message: chart-name + ": data must not be empty") + } +} + +// Validate multi-series data (labels + series array) +#let validate-series-data(data, chart-name) = { + assert(type(data) == dictionary, message: chart-name + ": data must be a dictionary") + assert("labels" in data, message: chart-name + ": data must have 'labels' key") + assert("series" in data, message: chart-name + ": data must have 'series' key") + assert(data.labels.len() > 0, message: chart-name + ": labels must not be empty") + assert(data.series.len() > 0, message: chart-name + ": series must not be empty") + for (i, s) in data.series.enumerate() { + assert("name" in s, message: chart-name + ": series[" + str(i) + "] must have 'name' key") + assert("values" in s, message: chart-name + ": series[" + str(i) + "] must have 'values' key") + assert(s.values.len() == data.labels.len(), + message: chart-name + ": series '" + label-str(s.name) + "' has " + str(s.values.len()) + " values but " + str(data.labels.len()) + " labels") + } +} + +// Validate scatter data (x + y arrays) +#let validate-scatter-data(data, chart-name) = { + if type(data) == dictionary { + assert("x" in data, message: chart-name + ": data must have 'x' key") + assert("y" in data, message: chart-name + ": data must have 'y' key") + assert(data.x.len() > 0, message: chart-name + ": x values must not be empty") + assert(data.x.len() == data.y.len(), + message: chart-name + ": x (" + str(data.x.len()) + ") and y (" + str(data.y.len()) + ") must have same length") + } + if type(data) == array { + assert(data.len() > 0, message: chart-name + ": data must not be empty") + } +} + +// Validate bubble data (x + y + size arrays) +#let validate-bubble-data(data, chart-name) = { + validate-scatter-data(data, chart-name) + if type(data) == dictionary { + assert("size" in data, message: chart-name + ": data must have 'size' key") + assert(data.size.len() == data.x.len(), + message: chart-name + ": size (" + str(data.size.len()) + ") must match x/y length (" + str(data.x.len()) + ")") + } +} + +// Validate heatmap data (rows + cols + values 2D array) +#let validate-heatmap-data(data, chart-name) = { + assert(type(data) == dictionary, message: chart-name + ": data must be a dictionary") + assert("rows" in data, message: chart-name + ": data must have 'rows' key") + assert("cols" in data, message: chart-name + ": data must have 'cols' key") + assert("values" in data, message: chart-name + ": data must have 'values' key") + assert(data.rows.len() > 0, message: chart-name + ": rows must not be empty") + assert(data.cols.len() > 0, message: chart-name + ": cols must not be empty") + assert(data.values.len() == data.rows.len(), + message: chart-name + ": values has " + str(data.values.len()) + " rows but " + str(data.rows.len()) + " row labels") +} + +// Validate calendar heatmap data +#let validate-calendar-data(data, chart-name) = { + assert(type(data) == dictionary, message: chart-name + ": data must be a dictionary") + assert("dates" in data, message: chart-name + ": data must have 'dates' key") + assert("values" in data, message: chart-name + ": data must have 'values' key") + assert(data.dates.len() > 0, message: chart-name + ": dates must not be empty") + assert(data.dates.len() == data.values.len(), + message: chart-name + ": dates (" + str(data.dates.len()) + ") and values (" + str(data.values.len()) + ") must have same length") +} + +// Validate correlation matrix +#let validate-correlation-data(data, chart-name) = { + assert(type(data) == dictionary, message: chart-name + ": data must be a dictionary") + assert("labels" in data, message: chart-name + ": data must have 'labels' key") + assert("values" in data, message: chart-name + ": data must have 'values' key") + assert(data.labels.len() > 0, message: chart-name + ": labels must not be empty") + assert(data.values.len() == data.labels.len(), + message: chart-name + ": values must be a square matrix matching labels length") +} + +// Validate numeric value (for gauge, progress) +#let validate-number(value, chart-name) = { + assert(type(value) == int or type(value) == float, + message: chart-name + ": value must be a number, got " + str(type(value))) +} + +// Validate boxplot data (labels + boxes array) +#let validate-boxplot-data(data, chart-name) = { + assert(type(data) == dictionary, message: chart-name + ": data must be a dictionary") + assert("labels" in data, message: chart-name + ": data must have 'labels' key") + assert("boxes" in data, message: chart-name + ": data must have 'boxes' key") + assert(data.labels.len() > 0, message: chart-name + ": labels must not be empty") + assert(data.labels.len() == data.boxes.len(), + message: chart-name + ": labels and boxes must have same length") + for (i, b) in data.boxes.enumerate() { + for key in ("min", "q1", "median", "q3", "max") { + assert(key in b, + message: chart-name + ": box[" + str(i) + "] must have '" + key + "' key") + } + } +} + +// Validate dual-axis data (labels + left/right series) +#let validate-dual-axis-data(data, chart-name) = { + assert(type(data) == dictionary, message: chart-name + ": data must be a dictionary") + assert("labels" in data, message: chart-name + ": data must have 'labels' key") + assert("left" in data, message: chart-name + ": data must have 'left' key") + assert("right" in data, message: chart-name + ": data must have 'right' key") + assert("name" in data.left, message: chart-name + ": left must have 'name' key") + assert("values" in data.left, message: chart-name + ": left must have 'values' key") + assert("name" in data.right, message: chart-name + ": right must have 'name' key") + assert("values" in data.right, message: chart-name + ": right must have 'values' key") + assert(data.left.values.len() == data.labels.len(), message: chart-name + ": left values must match labels length") + assert(data.right.values.len() == data.labels.len(), message: chart-name + ": right values must match labels length") +} + +// Validate ring-progress data (array of dicts with name, value, max) +#let validate-ring-data(entries, chart-name) = { + assert(type(entries) == array, + message: chart-name + ": entries must be an array") + assert(entries.len() > 0, + message: chart-name + ": entries must not be empty") + for (i, e) in entries.enumerate() { + assert(type(e) == dictionary, + message: chart-name + ": entries[" + str(i) + "] must be a dictionary") + for key in ("name", "value", "max") { + assert(key in e, + message: chart-name + ": entries[" + str(i) + "] must have '" + key + "' key") + } + assert(type(e.value) == int or type(e.value) == float, + message: chart-name + ": entries[" + str(i) + "].value must be numeric") + assert(type(e.max) == int or type(e.max) == float, + message: chart-name + ": entries[" + str(i) + "].max must be numeric") + assert(e.max > 0, + message: chart-name + ": entries[" + str(i) + "].max must be positive") + } +} + +// Validate histogram data (raw numeric array) +#let validate-histogram-data(values, chart-name) = { + assert(type(values) == array, message: chart-name + ": values must be an array") + assert(values.len() > 0, message: chart-name + ": values must not be empty") + for (i, v) in values.enumerate() { + assert(type(v) == int or type(v) == float, + message: chart-name + ": values[" + str(i) + "] must be numeric, got " + str(type(v))) + } +} + +// Validate grouped-stacked data (labels + groups, each with name + segments) +#let validate-grouped-stacked-data(data, chart-name) = { + assert(type(data) == dictionary, message: chart-name + ": data must be a dictionary") + assert("labels" in data, message: chart-name + ": data must have 'labels' key") + assert("groups" in data, message: chart-name + ": data must have 'groups' key") + assert(data.labels.len() > 0, message: chart-name + ": labels must not be empty") + assert(data.groups.len() > 0, message: chart-name + ": groups must not be empty") + let n-labels = data.labels.len() + for (gi, g) in data.groups.enumerate() { + assert(type(g) == dictionary, + message: chart-name + ": groups[" + str(gi) + "] must be a dictionary") + assert("name" in g, + message: chart-name + ": groups[" + str(gi) + "] must have 'name' key") + assert("segments" in g, + message: chart-name + ": groups[" + str(gi) + "] must have 'segments' key") + assert(g.segments.len() > 0, + message: chart-name + ": groups[" + str(gi) + "] must have at least one segment") + for (si, seg) in g.segments.enumerate() { + assert(type(seg) == dictionary, + message: chart-name + ": groups[" + str(gi) + "].segments[" + str(si) + "] must be a dictionary") + assert("name" in seg, + message: chart-name + ": groups[" + str(gi) + "].segments[" + str(si) + "] must have 'name' key") + assert("values" in seg, + message: chart-name + ": groups[" + str(gi) + "].segments[" + str(si) + "] must have 'values' key") + assert(seg.values.len() == n-labels, + message: chart-name + ": segment '" + label-str(seg.name) + "' has " + str(seg.values.len()) + " values but " + str(n-labels) + " labels") + } + } +} + +// Validate chord data (labels + square matrix) +#let validate-chord-data(data, chart-name) = { + assert(type(data) == dictionary, message: chart-name + ": data must be a dictionary") + assert("labels" in data, message: chart-name + ": data must have 'labels' key") + assert("matrix" in data, message: chart-name + ": data must have 'matrix' key") + assert(type(data.labels) == array, message: chart-name + ": labels must be an array") + assert(type(data.matrix) == array, message: chart-name + ": matrix must be an array") + let n = data.labels.len() + assert(n > 0, message: chart-name + ": labels must not be empty") + assert(data.matrix.len() == n, + message: chart-name + ": matrix must have " + str(n) + " rows to match labels, got " + str(data.matrix.len())) + for (i, row) in data.matrix.enumerate() { + assert(type(row) == array, + message: chart-name + ": matrix[" + str(i) + "] must be an array") + assert(row.len() == n, + message: chart-name + ": matrix[" + str(i) + "] must have " + str(n) + " columns, got " + str(row.len())) + } +} + +// Validate sankey data (nodes + flows) +#let validate-sankey-data(data, chart-name) = { + assert(type(data) == dictionary, message: chart-name + ": data must be a dictionary") + assert("nodes" in data, message: chart-name + ": data must have 'nodes' key") + assert("flows" in data, message: chart-name + ": data must have 'flows' key") + assert(data.nodes.len() > 0, message: chart-name + ": nodes must not be empty") + assert(data.flows.len() > 0, message: chart-name + ": flows must not be empty") + let n = data.nodes.len() + for (i, f) in data.flows.enumerate() { + assert(type(f) == dictionary, + message: chart-name + ": flows[" + str(i) + "] must be a dictionary") + for key in ("from", "to", "value") { + assert(key in f, + message: chart-name + ": flows[" + str(i) + "] must have '" + key + "' key") + } + assert(f.from >= 0 and f.from < n, + message: chart-name + ": flows[" + str(i) + "].from index out of range") + assert(f.to >= 0 and f.to < n, + message: chart-name + ": flows[" + str(i) + "].to index out of range") + assert(f.value > 0, + message: chart-name + ": flows[" + str(i) + "].value must be positive") + } +} + +// Validate bullet chart data (single) +#let validate-bullet-data(data, chart-name) = { + assert(type(data) == dictionary, message: chart-name + ": data must be a dictionary") + assert("value" in data, message: chart-name + ": data must have 'value' key") + assert("target" in data, message: chart-name + ": data must have 'target' key") + assert("ranges" in data, message: chart-name + ": data must have 'ranges' key") + assert(type(data.value) == int or type(data.value) == float, + message: chart-name + ": value must be numeric") + assert(type(data.target) == int or type(data.target) == float, + message: chart-name + ": target must be numeric") + assert(type(data.ranges) == array, message: chart-name + ": ranges must be an array") + assert(data.ranges.len() == 3, message: chart-name + ": ranges must have exactly 3 thresholds") +} + +// Validate bullet charts data (multiple) +#let validate-bullet-charts-data(data, chart-name) = { + assert(type(data) == dictionary, message: chart-name + ": data must be a dictionary") + assert("bullets" in data, message: chart-name + ": data must have 'bullets' key") + assert(type(data.bullets) == array, message: chart-name + ": bullets must be an array") + assert(data.bullets.len() > 0, message: chart-name + ": bullets must not be empty") + for (i, b) in data.bullets.enumerate() { + validate-bullet-data(b, chart-name + ".bullets[" + str(i) + "]") + } +} + +// Validate slope chart data (labels + start-values + end-values, same length) +#let validate-slope-data(data, chart-name) = { + assert(type(data) == dictionary, message: chart-name + ": data must be a dictionary") + assert("labels" in data, message: chart-name + ": data must have 'labels' key") + assert("start-values" in data, message: chart-name + ": data must have 'start-values' key") + assert("end-values" in data, message: chart-name + ": data must have 'end-values' key") + assert(data.labels.len() > 0, message: chart-name + ": labels must not be empty") + assert(data.labels.len() == data.start-values.len(), + message: chart-name + ": labels (" + str(data.labels.len()) + ") and start-values (" + str(data.start-values.len()) + ") must have same length") + assert(data.labels.len() == data.end-values.len(), + message: chart-name + ": labels (" + str(data.labels.len()) + ") and end-values (" + str(data.end-values.len()) + ") must have same length") +} + +// Validate dumbbell data — same shape as slope data +#let validate-dumbbell-data = validate-slope-data + +// Validate diverging bar data (labels + left-values + right-values) +#let validate-diverging-data(data, chart-name) = { + assert(type(data) == dictionary, message: chart-name + ": data must be a dictionary") + assert("labels" in data, message: chart-name + ": data must have 'labels' key") + assert("left-values" in data, message: chart-name + ": data must have 'left-values' key") + assert("right-values" in data, message: chart-name + ": data must have 'right-values' key") + assert(type(data.labels) == array, message: chart-name + ": labels must be an array") + assert(type(data.left-values) == array, message: chart-name + ": left-values must be an array") + assert(type(data.right-values) == array, message: chart-name + ": right-values must be an array") + assert(data.labels.len() > 0, message: chart-name + ": labels must not be empty") + assert(data.left-values.len() == data.labels.len(), + message: chart-name + ": left-values (" + str(data.left-values.len()) + ") must match labels length (" + str(data.labels.len()) + ")") + assert(data.right-values.len() == data.labels.len(), + message: chart-name + ": right-values (" + str(data.right-values.len()) + ") must match labels length (" + str(data.labels.len()) + ")") +} + +// Validate multi-scatter data +#let validate-multi-scatter-data(data, chart-name) = { + assert(type(data) == dictionary, message: chart-name + ": data must be a dictionary") + assert("series" in data, message: chart-name + ": data must have 'series' key") + assert(data.series.len() > 0, message: chart-name + ": series must not be empty") + for (i, s) in data.series.enumerate() { + assert("name" in s, message: chart-name + ": series[" + str(i) + "] must have 'name' key") + assert("points" in s, message: chart-name + ": series[" + str(i) + "] must have 'points' key") + assert(s.points.len() > 0, message: chart-name + ": series '" + label-str(s.name) + "' must have at least one point") + } +} + +// Validate multi-bubble data (series of (x, y, size) points) +#let validate-multi-bubble-data(data, chart-name) = { + validate-multi-scatter-data(data, chart-name) + for (i, s) in data.series.enumerate() { + for (j, pt) in s.points.enumerate() { + assert(pt.len() >= 3, + message: chart-name + ": series '" + label-str(s.name) + "' point[" + str(j) + "] must have at least 3 elements (x, y, size)") + } + } +} + +// Validate violin data (labels + datasets of raw observations) +#let validate-violin-data(data, chart-name) = { + assert(type(data) == dictionary, message: chart-name + ": data must be a dictionary") + assert("labels" in data, message: chart-name + ": data must have 'labels' key") + assert("datasets" in data, message: chart-name + ": data must have 'datasets' key") + assert(type(data.labels) == array, message: chart-name + ": labels must be an array") + assert(type(data.datasets) == array, message: chart-name + ": datasets must be an array") + assert(data.labels.len() > 0, message: chart-name + ": labels must not be empty") + assert(data.labels.len() == data.datasets.len(), + message: chart-name + ": labels (" + str(data.labels.len()) + ") and datasets (" + str(data.datasets.len()) + ") must have same length") + for (i, ds) in data.datasets.enumerate() { + assert(type(ds) == array, + message: chart-name + ": datasets[" + str(i) + "] must be an array") + assert(ds.len() > 0, + message: chart-name + ": datasets[" + str(i) + "] must not be empty") + for (j, v) in ds.enumerate() { + assert(type(v) == int or type(v) == float, + message: chart-name + ": datasets[" + str(i) + "][" + str(j) + "] must be numeric") + } + } +} + +// Validate sunburst data (hierarchical: root with name, children array) +#let validate-sunburst-data(data, chart-name) = { + assert(type(data) == dictionary, message: chart-name + ": data must be a dictionary") + assert("name" in data, message: chart-name + ": root must have 'name' key") + assert("children" in data, message: chart-name + ": root must have 'children' key") + assert(type(data.children) == array, message: chart-name + ": children must be an array") + assert(data.children.len() > 0, message: chart-name + ": children must not be empty") + for (i, child) in data.children.enumerate() { + assert(type(child) == dictionary, + message: chart-name + ": children[" + str(i) + "] must be a dictionary") + assert("name" in child, + message: chart-name + ": children[" + str(i) + "] must have 'name' key") + assert("value" in child, + message: chart-name + ": children[" + str(i) + "] must have 'value' key") + assert(type(child.value) == int or type(child.value) == float, + message: chart-name + ": children[" + str(i) + "].value must be numeric") + } +} + +// Validate timeline data (events array, each with date and title) +#let validate-timeline-data(data, chart-name) = { + assert(type(data) == dictionary, message: chart-name + ": data must be a dictionary") + assert("events" in data, message: chart-name + ": data must have 'events' key") + assert(type(data.events) == array, message: chart-name + ": events must be an array") + assert(data.events.len() > 0, message: chart-name + ": events must not be empty") + for (i, ev) in data.events.enumerate() { + assert(type(ev) == dictionary, + message: chart-name + ": events[" + str(i) + "] must be a dictionary") + for key in ("date", "title") { + assert(key in ev, + message: chart-name + ": events[" + str(i) + "] must have '" + key + "' key") + } + } +} + +// Validate word cloud data (words array, each with text and weight) +#let validate-wordcloud-data(data, chart-name) = { + assert(type(data) == dictionary, message: chart-name + ": data must be a dictionary") + assert("words" in data, message: chart-name + ": data must have 'words' key") + assert(type(data.words) == array, message: chart-name + ": words must be an array") + assert(data.words.len() > 0, message: chart-name + ": words must not be empty") + for (i, w) in data.words.enumerate() { + assert(type(w) == dictionary, + message: chart-name + ": words[" + str(i) + "] must be a dictionary") + assert("text" in w, + message: chart-name + ": words[" + str(i) + "] must have 'text' key") + assert("weight" in w, + message: chart-name + ": words[" + str(i) + "] must have 'weight' key") + assert(type(w.weight) == int or type(w.weight) == float, + message: chart-name + ": words[" + str(i) + "].weight must be numeric") + } +} + +// Validate gantt data (tasks array, each with name/start/end) +#let validate-gantt-data(data, chart-name) = { + assert(type(data) == dictionary, message: chart-name + ": data must be a dictionary") + assert("tasks" in data, message: chart-name + ": data must have 'tasks' key") + assert(type(data.tasks) == array, message: chart-name + ": tasks must be an array") + assert(data.tasks.len() > 0, message: chart-name + ": tasks must not be empty") + for (i, task) in data.tasks.enumerate() { + assert(type(task) == dictionary, + message: chart-name + ": tasks[" + str(i) + "] must be a dictionary") + for key in ("name", "start", "end") { + assert(key in task, + message: chart-name + ": tasks[" + str(i) + "] must have '" + key + "' key") + } + assert(type(task.start) == int or type(task.start) == float, + message: chart-name + ": tasks[" + str(i) + "].start must be numeric") + assert(type(task.end) == int or type(task.end) == float, + message: chart-name + ": tasks[" + str(i) + "].end must be numeric") + assert(task.start < task.end, + message: chart-name + ": tasks[" + str(i) + "] start (" + str(task.start) + ") must be less than end (" + str(task.end) + ")") + } +} + +#let validate-line-style(line-interpolation, smooth-radius, chart-name) = { + assert( + line-interpolation in ("linear", "smooth", "catmull-rom"), + message: chart-name + ": line-interpolation must be 'linear', 'smooth', or 'catmull-rom'" + ) + assert( + type(smooth-radius) == int and smooth-radius >= 0 and smooth-radius <= 5, + message: chart-name + ": smooth-radius must be an integer from 0 to 5" + ) +} diff --git a/packages/preview/primaviz/0.12.0/typst.toml b/packages/preview/primaviz/0.12.0/typst.toml new file mode 100644 index 0000000000..4bae24157c --- /dev/null +++ b/packages/preview/primaviz/0.12.0/typst.toml @@ -0,0 +1,11 @@ +[package] +name = "primaviz" +version = "0.12.0" +entrypoint = "src/lib.typ" +authors = ["phiat"] +license = "MIT" +description = "A charting library with 50+ chart types, 7 themes, and zero dependencies — built entirely with native primitives" +keywords = ["chart", "plot", "visualization", "graph", "data", "dashboard"] +repository = "https://github.com/phiat/primaviz" +compiler = "0.13.0" +exclude = [".beads/*", "tests/*", "justfile", "AGENTS.md", "CLAUDE.md", "CONTRIBUTING.md", "scripts/*", "examples/*", "screenshots/*", "data/*"]