/ tutorials / wave-acoustics
Sign in
tutorials / wave-acoustics
wave-acoustics / src / plot / palette.ts
150 lines · 5.2 KBCodeBlameHistory
d8098edAn interactive tutorial on the wave equation in acoustics: strings, drums and rooms, measured by earJeremy Magland 1// Colours, assigned by the job each one does.
2//
3// The set is the reference data-viz palette for a dark surface (#1a1a19),
4// taken unchanged from the sibling laplace-dirichlet tutorial, where it was
5// validated as a set:
6//
7// node scripts/validate_palette.js \
8// "#3987e5,#d95926,#199e70,#c98500,#d55181,#008300,#9085e9,#e66767" --mode dark
9// lightness band PASS chroma floor PASS CVD separation PASS (worst 8.4)
10// normal-vision floor PASS (worst 19.3) contrast vs surface PASS
11//
12// Only the assignment of hues to meanings is new here; the particle-filters
13// tutorial reuses the same eight hexes with its own assignment. The worst adjacent
14// pair sits in the 6-8 CVD band's neighbourhood, so every plot that shows more
15// than two series also carries a legend, a distinct marker shape per series,
16// and direct end-labels: identity is never colour alone. If you add a series,
17// re-run the validator rather than picking a hex that looks nice.
19export const surface = '#1a1a19'
21/**
22 * Identity: which method produced a curve or a sound. Fixed slots, never
23 * cycled and never reassigned when a plot shows a subset.
24 */
25export const method = {
26 /** second-order leapfrog finite differences, the workhorse */
27 fd2: '#3987e5',
28 /** fourth-order finite differences, in space or in space and time */
29 fd4: '#d95926',
30 /** modal synthesis and spectral methods: a sum of exact standing waves */
31 modal: '#199e70',
32 /** delay lines: the digital waveguide and Karplus-Strong */
33 waveguide: '#c98500',
34 /** boundary treatments: cut-cell stencils, absorbing layers, image sources */
35 boundary: '#d55181',
36 /** the exact answer when it is drawn as a series rather than as reference */
37 exact: '#9085e9',
38 /** a spare slot, for the extra series a single chapter needs */
39 alt: '#e66767',
40} as const
42export type MethodKey = keyof typeof method
44/** Marker shapes, so that identity survives greyscale and colour blindness. */
45export const marker: Record<MethodKey, 'circle' | 'square' | 'triangle' | 'diamond' | 'cross' | 'plus' | 'star'> = {
46 fd2: 'circle',
47 fd4: 'square',
48 modal: 'triangle',
49 waveguide: 'diamond',
50 boundary: 'cross',
51 exact: 'plus',
52 alt: 'star',
55/** State. Reserved: never reused as a series colour. */
56export const status = {
57 good: '#008300',
58 warning: '#c98500',
59 critical: '#e66767',
60} as const
62/** Sequential ramp for magnitude (an energy, a spectrum level): one hue. */
63export const sequential = [
64 '#cde2fb',
65 '#b7d3f6',
66 '#9ec5f4',
67 '#86b6ef',
68 '#6da7ec',
69 '#5598e7',
70 '#3987e5',
71 '#2a78d6',
72 '#256abf',
73 '#1c5cab',
74 '#184f95',
75 '#104281',
76 '#0d366b',
77] as const
79/** Diverging pair for a signed field: two poles, neutral grey midpoint. */
80export const diverging = {
81 low: '#3987e5',
82 mid: '#383835',
83 high: '#e66767',
84} as const
86/** Chart chrome and ink. Text always wears these, never a series colour. */
87export const ink = {
88 primary: '#e8e6df',
89 secondary: '#c3c2b7',
90 muted: '#8a8880',
91 grid: '#272724',
92 axis: '#3c3c38',
93 /** the exact solution: the reference everything is measured against */
94 reference: '#8a8880',
95 /** grid nodes, receivers, individual sample points */
96 node: '#e8e6df',
97 /** a family of curves drawn as a mass: many modes, many images */
98 family: '#4a4a45',
99} as const
101function hexToRgb(hex: string): [number, number, number] {
102 const v = parseInt(hex.slice(1), 16)
103 return [(v >> 16) & 255, (v >> 8) & 255, v & 255]
106function rgbToHex(r: number, g: number, b: number): string {
107 const c = (x: number) => Math.max(0, Math.min(255, Math.round(x))).toString(16).padStart(2, '0')
108 return `#${c(r)}${c(g)}${c(b)}`
111function mix(a: string, b: string, t: number): string {
112 const [r1, g1, b1] = hexToRgb(a)
113 const [r2, g2, b2] = hexToRgb(b)
114 return rgbToHex(r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t)
117/** Sequential colour for t in [0, 1]: light at 0 (near zero), dark at 1. */
118export function seqColor(t: number): string {
119 if (!isFinite(t)) return ink.grid
120 const u = Math.max(0, Math.min(1, t)) * (sequential.length - 1)
121 const i = Math.floor(u)
122 return i >= sequential.length - 1 ? sequential[sequential.length - 1] : mix(sequential[i], sequential[i + 1], u - i)
125/** Diverging colour for t in [-1, 1]: blue below, grey at zero, red above. */
126export function divColor(t: number): string {
127 if (!isFinite(t)) return ink.grid
128 const u = Math.max(-1, Math.min(1, t))
129 return u < 0 ? mix(diverging.mid, diverging.low, -u) : mix(diverging.mid, diverging.high, u)
132/** Mix a colour towards the surface, for a recessive version of a series. */
133export function faded(c: string, t = 0.55): string {
134 return mix(c, surface, t)
137/**
138 * One method at several settings of a parameter (a Courant number, a grid
139 * size): a family rather than several identities, so it is drawn in shades of
140 * one hue, light to dark in the order of the parameter, and every member also
141 * carries its own marker shape. Only the lighter two-thirds of the
142 * sequential ramp is used, because its dark end sinks into this page's
143 * surface.
144 */
145export function familyColor(k: number, n: number): string {
146 const t = n <= 1 ? 0.3 : 0.05 + (0.62 * k) / (n - 1)
147 return seqColor(t)
150export const familyShapes = ['circle', 'square', 'triangle', 'diamond', 'star', 'plus', 'cross'] as const
↑↓ move↵openescclose