| 1 | import type { AreaVariant } from "./chart-context" |
| 2 | import { rgb, type |
| 3 | |
| 4 | // 4×4 ordered (Bayer) matrix, normalized to 0–1 thresholds - the exact matrix |
| 5 | // the legacy chart dithers with. |
| 6 | export const BAYER = |
| 7 | [0, 8, 2, |
| 8 | [12, 4, 14, |
| 9 | [3, 11, 1, |
| 10 | [15, 7, 13, |
| 11 | ].map((row) |
| 12 | |
| 13 | export const CELL = |
| 14 | export const MAX_COLS = |
| 15 | export const MAX_ROWS = |
| 16 | // Opacity of the top border outline (just under solid, so it reads as a soft |
| 17 | // edge rather than a hard line). See the note on colour vs opacity below. |
| 18 | export const BORDER_ALPHA = |
| 19 | // Opacity of a dither "off" cell relative to an "on" cell. The scatter modulates |
| 20 | // between these two tiers of the *same* colour instead of leaving holes, so the |
| 21 | // background never shows through as stark white on a light theme. |
| 22 | export const OFF_TIER = |
| 23 | |
| 24 | export type PaintOpts |
| 25 | variant: AreaVariant |
| 26 | intensity: number |
| 27 | dim: number |
| 28 | stacked: boolean |
| 29 | sparse?: number |
| 30 | } |
| 31 | |
| 32 | // Colour vs opacity - the guiding rule for the whole engine: |
| 33 | // |
| 34 | // Work with opacities instead of different shades of the same color. This will |
| 35 | // make sure it looks good on both light and dark mode. |
| 36 | // |
| 37 | // So every pixel is the series' single `fill` colour and we vary only its alpha. |
| 38 | // The old lighter `line` / near-white `star` shades were dropped: a shade that |
| 39 | // pops on a dark background reads as a jarring bright speck on a light one, while |
| 40 | // the same colour at a lower opacity simply blends into whatever sits behind it. |
| 41 | |
| 42 | /** |
| 43 | * Fill one backing-canvas column `x` from row `top` down to `floor` with the |
| 44 | * ordered-dither scatter - solid at the floor, dissolving upward so it *fades |
| 45 | * out toward the value line* - then cap the top with a soft border outline in |
| 46 | * the series colour. Density drives opacity (see the note above), so the fade |
| 47 | * reads correctly against both light and dark backgrounds. The single source of |
| 48 | * the dither look across area / line / bar. |
| 49 | */ |
| 50 | export function paintColumn( |
| 51 | octx: CanvasRenderingContext2D |
| 52 | x: number, |
| 53 | top: number, |
| 54 | floor: number, |
| 55 | seed: Seed |
| 56 | { variant, intensity |
| 57 | ) { |
| 58 | const t = |
| 59 | const f = |
| 60 | const depth = |
| 61 | if (depth <= |
| 62 | octx.fillStyle = |
| 63 | octx.fillRect(x |
| 64 | return |
| 65 | } |
| 66 | const bias = |
| 67 | for (let y |
| 68 | // Inverted falloff: 0 at the top line, 1 at the floor - dense at the |
| 69 | // bottom, thinning as it rises toward the outline. |
| 70 | let density = |
| 71 | if (stacked) density |
| 72 | if (variant === |
| 73 | const lit = |
| 74 | variant === "solid" |
| 75 | density > BAYER[ |
| 76 | // "dotted" keeps real gaps for its open look; every other variant covers |
| 77 | // the cell and lets the dither ride the alpha (on = full tier, off = a |
| 78 | // faint tint) so nothing shows the background through as white. |
| 79 | if (variant === |
| 80 | // Density → alpha (see the colour-vs-opacity note above). |
| 81 | const k = |
| 82 | const alpha = |
| 83 | octx.fillStyle = |
| 84 | octx.fillRect(x |
| 85 | } |
| 86 | // Top border outline - the shape's edge now that the fill fades out here. |
| 87 | // Kept just under full opacity, with a faint feather row beneath, so it reads |
| 88 | // as a soft edge rather than a hard line floating over the fade. |
| 89 | octx.fillStyle = |
| 90 | octx.fillRect(x |
| 91 | if (depth > |
| 92 | octx.fillStyle = |
| 93 | octx.fillRect(x |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | /** Linear-resample a per-index fraction array to `cols` columns. */ |
| 98 | export function resample(src |
| 99 | const out = |
| 100 | const last = |
| 101 | for (let c |
| 102 | const t = |
| 103 | const i = |
| 104 | const f = |
| 105 | const a = |
| 106 | const b = |
| 107 | out[c] = |
| 108 | } |
| 109 | return out |
| 110 | } |
| 111 | |
| 112 | /** Backing-canvas resolution for a plot rect - low-res, scaled up `pixelated`. */ |
| 113 | export function backingSize(width |
| 114 | return { |
| 115 | cols: Math.min |
| 116 | rows: Math.min |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | // Bloom - a real "shader" glow that comes from the colours themselves: a blurred |
| 121 | // copy of the rendered canvas, composited additively (`plus-lighter`) so each |
| 122 | // hue blooms in its own colour instead of a grey wash. Lives on a second canvas |
| 123 | // layered over the crisp one (which stays sharp/pixelated). |
| 124 | export type BloomLevel |
| 125 | export type BloomBlend |
| 126 | export type BloomConfig |
| 127 | blur: number |
| 128 | brightness: number |
| 129 | opacity: number |
| 130 | /** Saturation of the glow - >1 keeps it vividly in the dither's colour |
| 131 | * instead of washing toward white. */ |
| 132 | saturate?: number |
| 133 | blend?: BloomBlend |
| 134 | } |
| 135 | /** A preset name, a full config, or "off". */ |
| 136 | export type BloomInput |
| 137 | |
| 138 | const PRESET: Record |
| 139 | low: { blur: 3 |
| 140 | high: { blur: 5 |
| 141 | aura: { blur: 15 |
| 142 | } |
| 143 | |
| 144 | export type BloomStyle |
| 145 | filter: string |
| 146 | opacity: number |
| 147 | mixBlendMode: BloomBlend |
| 148 | imageRendering: "auto |
| 149 | } |
| 150 | |
| 151 | /** Style for the bloom *layer* canvas (a blurred, additive copy). null when off. */ |
| 152 | export function bloomLayerStyle( |
| 153 | input: BloomInput |
| 154 | active: boolean |
| 155 | ): BloomStyle | |
| 156 | if (!active || |
| 157 | const cfg = |
| 158 | return { |
| 159 | filter: `blur(${cfg |
| 160 | opacity: cfg.opacity |
| 161 | mixBlendMode: cfg.blend |
| 162 | imageRendering: "auto", |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | // Easing - gentle start + soft settle so entrances don't feel linear. |
| 167 | export const easeInOutCubic = |
| 168 | t < 0.5 ? |
| 169 | export const easeOutCubic = |
| 170 | export const clamp01 = |
| 171 | |
| 172 | /** Whether the OS asks for reduced motion (snap + steady stars). */ |
| 173 | export function prefersReducedMotion() |
| 174 | return ( |
| 175 | window.matchMedia?.(" |
| 176 | ) |
| 177 | } |