PageSourceSearch

https://tripgeo.com/js/hex-logo.js?v=369

js tripgeo.com collected 2026-09-25 21:05:57 UTC 29,163 bytes, 600 lines download raw bytes

1// TripGeo hexagon logo - stained glass edition. The mark is a ring of six pinwheel panels round a
2// centre hexagon (the inner triangles are gone), and behind it all a globe turns, so each of the
3// seven is a coloured pane of glass with the earth seen through it - a double exposure of the
4// hexagon and the world. The centre pane is a lighter blend of the six colours round it.
5//
6// The logo draws at once as a still (glass panels over an empty globe with its grid), then fetches
7// a small vector outline of the land (data/earth-land.json - Natural Earth 110m, thinned) and only
8// starts turning once that has arrived. The palette still slowly melts from one set of colours to
9// the next, as before. The old hexagon spin is switched off (see M.hexSpin) but left in place.
10//
11// The clock is a module-level singleton seeded from sessionStorage and every frame is a pure
12// function of it, so a logo that appears on another page - home to blog, or after a reload - picks
13// the animation up exactly where the last one left off instead of restarting.
14window.tgHexLogo = (function () {
15    'use strict';
16
17    const SIZE = 400, CX = SIZE / 2, CY = SIZE / 2, RO = 156;   // RO: the outer hexagon
18    const NS = 'http://www.w3.org/2000/svg';
19    const DEG = Math.PI / 180;
20
21    const OUTER = ['#F5A623', '#7AC943', '#29ABE2', '#1B75BC', '#9B51E0', '#EC2E8A'];
22
23    /* Never white, never near-white: no colour is allowed to get lighter than this (HSL lightness, %).
24       The seams between the pieces are the only white in the mark, and they are the gaps, not a colour. */
25    const MAX_LIGHT = 72, MIN_LIGHT = 12;
26
27    /* Tuning. Periods are in the comments so it is easy to re-pitch. */
28    const M = {
29        globeRate: 9,       // deg/s the earth turns (~40s a revolution), west to east
30        globeTilt: 20,      // deg the north pole leans towards the viewer
31        globeLon: 10,       // deg - the longitude facing us in the still (and at clock zero)
32        globeScale: 0.96,   // globe radius as a fraction of the outer hexagon's radius
33        cornerRound: 0.16,  // radius of the rounded outer corners, as a fraction of the same
34
35        oceanDark: 12,      // % lightness the sea sits below the pane's own colour...
36        landLight: 14,      // ...and the land above it, so the continents shine through the glass
37        gridLight: 22,      // the grid lines, lighter again but faint (gridOpacity)
38        gridOpacity: 0.30,
39
40        centreSat: 1.5,     // how strongly the centre keeps the blended hue when the six disagree
41        centreLight: 14,    // % lightness the centre sits above the panes' average, so it reads light
42
43        hueSwing: 8,        // deg - a small extra sway either side of whatever palette is showing
44        hueRate: 0.055,     //       (~114s), so even a held palette is never quite still
45        lightAmp: 5,        // +/- % lightness in the wave that runs around the six panes
46        lightRate: 0.40,    // rad/s (~16s)
47        lightLag: 0.85,     // phase step from one pane to the next
48
49        hexSpin: false,     // the old turning of the whole hexagon - off for now
50        spinDrift: 0.9,     // deg/s - steady drift of the mark, when hexSpin is on
51        spinSwing: 22,      // deg - sine on top, so it eases, stops and turns back
52        spinRate: 0.060,    // rad/s (~105s)
53
54        paletteHold: 22,    // s - each palette stays put this long...
55        paletteFade: 18     // s - ...then melts into the next one over this long
56    };
57
58    /* ---------------------------------------------------------------- clock
59       Seconds since the first logo of this browser-tab session was drawn. Kept in sessionStorage so
60       it survives a reload, and read through performance.now() so it stays smooth within a load. */
61    const CLOCK_KEY = 'tgHexLogoClock';
62    const perfStart = performance.now();
63    let baseSeconds = 0;
64
65    (function seedClock() {
66        const now = Date.now();
67        try {
68            const saved = parseFloat(sessionStorage.getItem(CLOCK_KEY));
69            if (isFinite(saved) && saved > 0 && saved <= now) {
70                baseSeconds = (now - saved) / 1000;
71                return;
72            }
73            sessionStorage.setItem(CLOCK_KEY, String(now));
74        } catch (e) {
75            // Private mode or storage blocked - the clock just starts at zero for this load.
76        }
77    })();
78
79    const clock = () => baseSeconds + (performance.now() - perfStart) / 1000;
80
81    /* ---------------------------------------------------------------- colour */
82    function hexToHsl(hex) {
83        const n = parseInt(hex.slice(1), 16);
84        const r = ((n >> 16) & 255) / 255, g = ((n >> 8) & 255) / 255, b = (n & 255) / 255;
85        const max = Math.max(r, g, b), min = Math.min(r, g, b), l = (max + min) / 2;
86        if (max === min) return [0, 0, l * 100];
87        const c = max - min;
88        let h;
89        if (max === r) h = ((g - b) / c) % 6;
90        else if (max === g) h = (b - r) / c + 2;
91        else h = (r - g) / c + 4;
92        h *= 60;
93        if (h < 0) h += 360;
94        return [h, (c / (1 - Math.abs(2 * l - 1))) * 100, l * 100];
95    }
96
97    /* The palettes: six [hue, saturation %, lightness %] colours, one per pane, in the order they sit
98       round the mark. The first is the brand palette and the order below is the order they play in. */
99    const ramp = (h0, h1, s, l0, l1) => [0, 1, 2, 3, 4, 5].map(function (k) {
100        const u = k / 5;
101        return [(h0 + (h1 - h0) * u + 360) % 360, s, l0 + (l1 - l0) * u];
102    });
103
104    const PALETTES = [
105        { name: 'default', outer: OUTER.map(hexToHsl) },
106        { name: 'vivid', outer: [[32, 100, 50], [92, 100, 44], [196, 100, 47], [222, 100, 52], [284, 100, 62], [334, 100, 58]] },
107        { name: 'shades', outer: ramp(206, 200, 62, 56, 30) },
108        { name: 'fire', outer: [[352, 80, 32], [6, 90, 40], [16, 96, 47], [27, 100, 50], [38, 100, 50], [48, 100, 48]] },
109        { name: 'ice', outer: [[232, 56, 32], [220, 68, 40], [208, 78, 48], [198, 82, 54], [190, 74, 58], [204, 56, 60]] },
110        { name: 'monochrome', outer: ramp(215, 215, 8, 56, 28) },
111        { name: 'neon', outer: [[312, 100, 58], [268, 100, 64], [200, 100, 52], [160, 100, 46], [84, 100, 48], [50, 100, 50]] },
112        { name: 'forest', outer: [[160, 50, 26], [140, 42, 30], [120, 42, 36], [98, 46, 42], [74, 44, 42], [40, 44, 36]] },
113        { name: 'sunset', outer: [[252, 50, 32], [282, 56, 38], [320, 68, 46], [346, 78, 52], [8, 88, 52], [30, 94, 50]] }
114    ];
115
116    const smooth = u => u * u * (3 - 2 * u);
117
118    // Hue takes the short way round the wheel; saturation and lightness blend straight across.
119    function lerpHsl(a, b, u) {
120        const dh = ((b[0] - a[0] + 540) % 360) - 180;
121        return [(a[0] + dh * u + 360) % 360, a[1] + (b[1] - a[1]) * u, a[2] + (b[2] - a[2]) * u];
122    }
123
124    function palette(t) {
125        const span = M.paletteHold + M.paletteFade;
126        const pos = t / span, n = PALETTES.length;
127        const i = Math.floor(pos) % n;
128        const within = (pos - Math.floor(pos)) * span;
129        const a = PALETTES[i];
130        if (within <= M.paletteHold) return a;
131
132        const b = PALETTES[(i + 1) % n];
133        const u = smooth((within - M.paletteHold) / M.paletteFade);
134        return { outer: a.outer.map(function (c, k) { return lerpHsl(c, b.outer[k], u); }) };
135    }
136
137    // A pane's colour with the hue sway and the brightness wave applied, as [h, s, l]
138    function tint(hsl, k, t, hueShift, wave) {
139        return [(hsl[0] + hueShift + 360) % 360, hsl[1],
140            hsl[2] + (wave ? M.lightAmp * Math.sin(t * M.lightRate - k * M.lightLag) : 0)];
141    }
142
143    /* The centre's colour, made from the six round it. A straight RGB average of a rainbow is a muddy
144       grey, so instead the hues are averaged as directions round the colour wheel (weighted by how
145       saturated each is), and the result keeps the panes' average saturation and lightness. For the
146       brand colours that lands on a light lavender. If the hues all but cancel, the saturation drops with
147       them, so the blend fades towards a neutral rather than flicking between hues. */
148    function blend(cols) {
149        let x = 0, y = 0, s = 0, l = 0;
150        cols.forEach(function (c) {
151            x += c[1] * Math.cos(c[0] * DEG); y += c[1] * Math.sin(c[0] * DEG);
152            s += c[1]; l += c[2];
153        });
154        const n = cols.length, pull = s ? Math.hypot(x, y) / s : 0;   // 0 = hues cancel, 1 = all agree
155        const h = (Math.atan2(y, x) / DEG + 360) % 360;
156        return [h, (s / n) * Math.min(1, M.centreSat * Math.sqrt(pull)), l / n + M.centreLight];
157    }
158
159    // As a CSS colour, lightness nudged by `dl` and held inside the limits so it never goes white
160    function css(hsl, dl) {
161        let l = hsl[2] + (dl || 0);
162        if (l < MIN_LIGHT) l = MIN_LIGHT; else if (l > MAX_LIGHT) l = MAX_LIGHT;
163        return 'hsl(' + hsl[0].toFixed(1) + ',' + hsl[1].toFixed(1) + '%,' + l.toFixed(1) + '%)';
164    }
165
166    /* ---------------------------------------------------------------- ring geometry
167       Outer hexagon of circumradius RO, inner hexagon of R = RO * (1 - ring). Each cut runs from an
168       inner vertex to the outer hexagon, swept round into the pinwheel, which gives the six trapezium
169       panes; the inner hexagon is a solid pane of its own. Every corner of the outer hexagon falls
170       inside one pane, so rounding it there rounds the whole mark. */
171    const f = n => Math.round(n * 100) / 100;
172    const f1 = n => Math.round(n * 10) / 10;
173
174    const pt = (r, k) => {
175        const a = -Math.PI / 3 * k;                  // negative: SVG y grows downwards
176        return [CX + r * Math.cos(a), CY + r * Math.sin(a)];
177    };
178
179    function panels(ring, reversed) {
180        const R = RO * (1 - ring), d = RO - R;
181        const V = [0, 1, 2, 3, 4, 5].map(k =>
181 pt(R, k));
182        const W = [0, 1, 2, 3, 4, 5].map(k => pt(RO, k));
183        const U = [0, 1, 2, 3, 4, 5].map(k => {
184            const a = V[k], b = V[(k + 1) % 6];
185            return [(b[0] - a[0]) / R, (b[1] - a[1]) / R];   // unit, along inner edge k
186        });
187        const E = k => [W[k][0] + d * U[k][0], W[k][1] + d * U[k][1]];
188
189        // Cut each outer corner back by rc along both edges and bow it round. The short side of the
190        // corner is only R long, so a very deep ring gets a smaller rounding.
191        const rc = Math.min(RO * M.cornerRound, R * 0.8);
192        const toward = (w, p) => {
193            const len = Math.hypot(p[0] - w[0], p[1] - w[1]);
194            return [w[0] + (p[0] - w[0]) * rc / len, w[1] + (p[1] - w[1]) * rc / len];
195        };
196
197        const out = [];
198        for (let k = 0; k < 6; k++) {
199            const n = (k + 1) % 6;
200            let pts = [V[k], V[n], E(n), toward(W[n], E(n)), [W[n][0], W[n][1], 'q'], toward(W[n], E(k)), E(k)];
201            // Mirror only the ring, never the globe - a mirrored earth would be the wrong way round
202            if (reversed) pts = pts.map(p => [SIZE - p[0], p[1], p[2]]);
203            out.push(pts);
204        }
205        const centre = reversed ? V.map(p => [SIZE - p[0], p[1]]) : V;
206        return { panes: out, centre: centre };
207    }
208
209    // Straight sides, except that a point marked 'q' is the control point of a curve to the next one
210    function toPath(pts) {
211        let d = 'M' + f(pts[0][0]) + ' ' + f(pts[0][1]);
212        for (let i = 1; i < pts.length; i++) {
213            const p = pts[i];
214            if (p[2] === 'q') { const e = pts[++i]; d += 'Q' + f(p[0]) + ' ' + f(p[1]) + ' ' + f(e[0]) + ' ' + f(e[1]); }
215            else d += 'L' + f(p[0]) + ' ' + f(p[1]);
216        }
217        return d + 'Z';
218    }
219
220    /* ---------------------------------------------------------------- the earth
221       The land arrives as rings of [lon, lat, lon, lat, ...] in degrees. Each ring is kept as its
222       longitudes in radians plus the sine and cosine of its latitudes, so a frame is only a handful
223       of multiplications a point. The grid is built here and needs no download. */
224    function prepare(flat) {
225        const n = flat.length / 2;
226        const lon = new Float64Array(n), sl = new Float64Array(n), cl = new Float64Array(n);
227        for (let i = 0; i < n; i++) {
228            lon[i] = flat[i * 2] * DEG;
229            const la = flat[i * 2 + 1] * DEG;
230            sl[i] = Math.sin(la); cl[i] = Math.cos(la);
231        }
232        return { n: n, lon: lon, sl: sl, cl: cl };
233    }
234
235    const GRID = (function () {
236        const lines = [];
237        for (let lo = -180; lo < 180; lo += 30) {
238            const a = [];
239            for (let la = -90; la <= 90; la += 5) a.push(lo, la);
240            lines.push(prepare(a));
241        }
242        for (let la = -60; la <= 60; la += 30) {
243            const a = [];
244            for (let lo = -180; lo <= 180; lo += 5) a.push(lo, la);
245            lines.push(prepare(a));
246        }
247        return lines;
248    })();
249
250    let land = null;           // prepared rings, once loaded
251    let landRequested = false;
252
253    // The data file sits beside this script's folder and carries the same cache-busting version
254    const SCRIPT_SRC = document.currentScript && document.currentScript.src;
255    function landUrl() {
256        try {
257            const u = new URL('../data/earth-land.json', SCRIPT_SRC || location.href);
258            if (SCRIPT_SRC) u.search = new URL(SCRIPT_SRC).search;
259            return u.href;
260        } catch (e) {
261            return '/data/earth-land.json';
262        }
263    }
264
265    function loadLand() {
266        if (landRequested || !window.fetch) return;
267        landRequested = true;
268        fetch(landUrl())
269            .then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
270            .then(function (rings) {
271                land = rings.map(prepare);
272                const t = clock();
273                instances.forEach(function (i) { i.draw(t); });
274                stills.forEach(function (i) { if (i.host.isConnected) i.draw(t); });
275                stills.clear();
276                start();
277            })
278            .catch(function (e) {
279                // No land, no spin - the still logo stays up, which is a perfectly good logo
280                console.warn('HexLogo: could not load the earth outline', e);
281            });
282    }
283
284    /* Orthographic view of the globe with (lon0, lat0) facing us. Returns x and y on the unit disc
285       (y up) and z, the depth - a point is on the near side when z > 0. */
286    function view(g, lon0, s0, c0) {
287        const n = g.n, x = new Float64Array(n), y = new Float64Array(n), z = new Float64Array(n);
288        for (let i = 0; i < n; i++) {
289            const dl = g.lon[i] - lon0, cd = Math.cos(dl);
290            x[i] = g.cl[i] * Math.sin(dl);
291            y[i] = c0 * g.sl[i] - s0 * g.cl[i] * cd;
292            z[i] = s0 * g.sl[i] + c0 * g.cl[i] * cd;
293        }
294        return { n: n, x: x, y: y, z: z };
295    }
296
297    /* A closed ring clipped to the near side. Where it goes over the horizon it is cut there, and it
298       comes back along the rim the short way to where it next reappears - so a continent sliding
299       round the edge stays a filled shape instead of collapsing or flooding the disc. */
300    function clipRing(v, G, out) {
301        const n = v.n;
302        let s = -1;
303        for (let i = 0; i < n; i++) if (v.z[i] > 0) { s = i; break; }
304        if (s < 0) return;                                   // wholly on the far side
305
306        const X = (x) => f1(CX + G * x), Y = (y) => f1(CY - G * y);
307        let d = 'M' + X(v.x[s]) + ' ' + Y(v.y[s]), exitA = 0;
308        for (let j = 1; j <= n; j++) {
309            const p = (s + j - 1) % n, q = (s + j) % n;
310            const pv = v.z[p] > 0, qv = v.z[q] > 0;
311            if (pv !== qv) {
312                const u = v.z[p] / (v.z[p] - v.z[q]);
313                const a = Math.atan2(v.y[p] + u * (v.y[q] - v.y[p]), v.x[p] + u * (v.x[q] - v.x[p]));
314                if (pv) {
315                    exitA = a;
316                    d += 'L' + X(Math.cos(a)) + ' ' + Y(Math.sin(a));
317                } else {
318                    const da = ((a - exitA + 3 * Math.PI) % (2 * Math.PI)) - Math.PI;
319                    const steps = Math.max(1, Math.ceil(Math.abs(da) / 0.12));
320                    for (let k = 1; k <= steps; k++) {
321                        const b = exitA + da * k / steps;
322                        d += 'L' + X(Math.cos(b)) + ' ' + Y(Math.sin(b));
323                    }
324                }
325            }
326            if (qv && j < n) d += 'L' + X(v.x[q]) + ' ' + Y(v.y[q]);
327        }
328        out.push(d + 'Z');
329    }
330
331    // An open line (the grid), drawn only where it is on the near side
332    function clipLine(v, G, out) {
333        const X = (x) => f1(CX + G * x), Y = (y) => f1(CY - G * y);
334        let d = '', drawing = false;
335        for (let i = 0; i < v.n; i++) {
336            const vis = v.z[i] > 0;
337            if (vis) {
338                if (!drawing && i > 0) {
339                    const p = i - 1, u = v.z[p] / (v.z[p] - v.z[i]);
340                    const a = Math.atan2(v.y[p] + u * (v.y[i] - v.y[p]), v.x[p] + u * (v.x[i] - v.x[p]));
341                    d += 'M' + X(Math.cos(a)) + ' ' + Y(Math.sin(a)) + 'L';
342                } else d += drawing ? 'L' : 'M';
343                d += X(v.x[i]) + ' ' + Y(v.y[i]);
344                drawing = true;
345            } else if (drawing) {
346                const p = i - 1, u = v.z[p] / (v.z[p] - v.z[i]);
347                const a = Math.atan2(v.y[p] + u * (v.y[i] - v.y[p]), v.x[p] + u * (v.x[i] - v.x[p]));
348                d += 'L' + X(Math.cos(a)) + ' ' + Y(Math.sin(a));
349                drawing = false;
350            }
351        }
352        if (d) out.push(d);
353    }
354
355    /* ---------------------------------------------------------------- view */
356    let uid = 0;
357
358    function el(name, attrs) {
359        const e = document.createElementNS(NS, name);
360        for (const k in attrs) e.setAttribute(k, attrs[k]);
361        return e;
362    }
363
364    function build(host, o) {
365        // The padding is the static logo's, which keeps the drawn size exactly where it was
366        const pad = Math.max(o.seamWidth, 4) + 2;
367        const id = 'tghx' + (++uid) + '-';
368        const geo = panels(o.ringDepth, o.reversed), shapes = geo.panes;
369        const G = RO * M.globeScale;       // globe radius
370
371        const svg = el('svg', {
372            xmlns: NS,
373            viewBox: (-pad) + ' ' + (-pad) + ' ' + (SIZE + pad * 2) + ' ' + (SIZE + pad * 2),
374            role: 'img',
375            'aria-label': o.label
376        });
377
378        /* Shared pieces, drawn once a frame and shown six times - once through each pane. They carry
379           no fill of their own, so each <use> colours them for its own pane. */
380        const defs = el('defs', {});
381        const landPath = el('path', { id: id + 'land' });
382        const gridPath = el('path', { id: id + 'grid', fill: 'none', 'stroke-width': 1.4, 'stroke-linecap': 'round' });
383        const disc = el('circle', { id: id + 'disc', cx: CX, cy: CY, r: f(G) });
384        defs.appendChild(landPath);
385        defs.appendChild(gridPath);
386        defs.appendChild(disc);
387
388        // Light on the upper left, dark at the limb, so the globe reads as a ball behind the glass
389        const shade = el('radialGradient', { id: id + 'shade', cx: '0.38', cy: '0.32', r: '0.75' });
390        [['0', '#ffffff', 0.22], ['0.45', '#ffffff', 0], ['0.8', '#000000', 0.12], ['1', '#000000', 0.32]]
391            .forEach(function (s) {
392                shade.appendChild(el('stop', { offset: s[0], 'stop-color': s[1], 'stop-opacity': s[2] }));
393            });
394        defs.appendChild(shade);
395
396        // Seven panes of glass: the six trapeziums, then the centre hexagon (index 6)
397        const glassShapes = shapes.concat([geo.centre]);
398
399        const clips = [];
400        for (let k = 0; k < 7; k++) {
401            const cp = el('clipPath', { id: id + 'c' + k });
402            const p = el('path', { d: toPath(glassShapes[k]) });
403            cp.appendChild(p);
404            defs.appendChild(cp);
405            clips.push(p);
406        }
407        svg.appendChild(defs);
408
409        const panes = [];
410        for (let k = 0; k < 7; k++) {
411            const g = el('g', { 'clip-path': 'url(#' + id + 'c' + k + ')' });
412            const glass = el('path', { d: toPath(glassShapes[k]) });
413            const sea = el('use', { href: '#' + id + 'disc' });
414            const earth = el('use', { href: '#' + id + 'land' });
415            const grid = el('use', { href: '#' + id + 'grid', 'stroke-opacity': M.gridOpacity });
416            const ball = el('use', { href: '#' + id + 'disc', fill: 'url(#' + id + 'shade)' });
417            g.appendChild(glass); g.appendChild(sea); g.appendChild(earth); g.appendChild(grid); g.appendChild(ball);
418            svg.appendChild(g);
419            panes.push({ glass: glass, sea: sea, earth: earth, grid: grid });
420        }
421
422        // The seams (leading, in stained glass terms) go over the top of everything
423        const gSeam = el('g', { fill: 'none', stroke: o.seamColor, 'stroke-width': f(o.seamWidth), 'stroke-linejoin': 'round' });
424        const seams = shapes.map(function (s) { const p = el('path', { d: toPath(s) }); gSeam.appendChild(p); return p; });
425        svg.appendChild(gSeam);
426
427        host.textContent = '';
428        host.appendChild(svg);
429
430        const moving = () => o.animate && !reduced;
431
432        return {
433            host: host,
434            draw: function (t) {
435                const live = moving();
436
437                if (M.hexSpin && live) {
438                    const spin = t * M.spinDrift + M.spinSwing * Math.sin(t * M.spinRate);
439                    const rot = 'rotate(' + f(spin) + ' ' + CX + ' ' + CY + ')';
440                    clips.forEach(function (p) { p.setAttribute('transform', rot); });
441                    panes.forEach(function (p) { p.glass.setAttribute('transform', rot); });
442                    seams.forEach(function (p) { p.setAttribute('transform', rot); });
443                }
444
445                // The globe only turns once the land is here; until then it holds at the still's view
446                const lon0 = (live && land ? M.globeLon - M.globeRate * t : M.globeLon) * DEG;
447                const s0 = Math.sin(M.globeTilt * DEG), c0 = Math.cos(M.globeTilt * DEG);
448
449                const gridD = [];
450                GRID.forEach(function (g) { clipLine(view(g, lon0, s0, c0), G, gridD); });
451                gridPath.setAttribute('d', gridD.join(''));
452
453                if (land) {
454                    const landD = [];
455                    land.forEach(function (g) { clipRing(view(g, lon0, s0, c0), G, landD); });
456                    landPath.setAttribute('d', landD.join(''));
457                }
458
459                // A still logo (Animate off, or reduced motion) stays in the brand colours
460                const colours = live ? palette(t) : PALETTES[0];
461                const hue = live ? M.hueSwing * Math.sin(t * M.hueRate) : 0;
462                const tints = [];
463                for (let k = 0; k < 7; k++) {
464                    // The centre pane (6) is a lighter blend of the six colours round it
465                    const c = k < 6 ? tint(colours.outer[k], k, t, hue, live) : blend(tints), p = panes[k];
466                    tints.push(c);
467                    p.glass.setAttribute('fill', css(c, 0));
468                    p.sea.setAttribute('fill', css(c, -M.oceanDark));
469                    p.earth.setAttribute('fill', css(c, M.landLight));
470                    p.grid.setAttribute('stroke', css(c, M.gridLight));
471                }
472            }
473        };
474    }
475
476    /* ---------------------------------------------------------------- loop
477       One rAF for every logo on the page, and it only runs once the land has loaded. Instances drop
478       out when their host leaves the DOM, which is how a Blazor page change tidies up - the component
479       needs no disposal call. */
480    const instances = new Set();   // animated logos
481    const stills = new Set();      // still logos waiting for the land, to be drawn once when it lands
482    let rafId = 0;
483    let reduced = false;
484
485    const motionQuery = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)') : null;
486    if (motionQuery) {
487        reduced = motionQuery.matches;
488        const onMotionChange = function (e) {
489            reduced = e.matches;
490            if (reduced) instances.forEach(function (i) { i.draw(clock()); });
491            else start();
492        };
493        if (motionQuery.addEventListener) motionQuery.addEventListener('change', onMotionChange);
494        else if (motionQuery.addListener) motionQuery.addListener(onMotionChange);
495    }
496
497    function tick() {
498        rafId = 0;
499        const t = clock();
500        instances.forEach(function (inst) {
501            if (!inst.host.isConnected) instances.delete(inst);
502            else inst.draw(t);
503        });
504        start();
505    }
506
507    function start() {
508        if (!rafId && !reduced && land && instances.size) rafId = requestAnimationFrame(tick);
509    }
510
511    /* ---------------------------------------------------------------- api */
512    function render(element, options) {
513        if (!element) return;
514        const o = options || {};
515        const opt = {
516            ringDepth: typeof o.ringDepth === 'number' ? o.ringDepth : 0.55,
517            seamWidth: typeof o.seamWidth === 'number' ? o.seamWidth : 5,
518            seamColor: o.seamColor || '#ffffff',
519            reversed: !!o.reversed,
520            label: o.label || 'TripGeo',
521            animate: o.animate !== false
522        };
523
524        // Already drawn with these options (by adopt(), from the prerendered HTML): leave it running
525        const sig = JSON.stringify(opt);
526        if (element.__tgHex && element.__tgHex.sig === sig && element.__tgHex.host.firstChild) return;
527
528        if (element.__tgHex) { instances.delete(element.__tgHex); stills.delete(element.__tgHex); }
529
530        const inst = build(element, opt);
531        inst.sig = sig;
532        element.__tgHex = inst;
533        inst.draw(clock());          // the still, now, so the box never flashes empty
534
535        if (opt.animate) instances.add(inst);
536        else if (!land) stills.add(inst);
537        loadLand();
538        start();
539    }
540
541    // Blazor does not need this - see the loop above - but it is here for anything that wants to
542    // stop a logo without removing it from the page.
543    function dispose(element) {
544        if (element && element.__tgHex) {
545            instances.delete(element.__tgHex);
546            stills.delete(element.__tgHex);
547            element.__tgHex = null;
548        }
549    }
550
551    // The six pane colours at time t as [hue, saturation, lightness] - handy for checking a palette
552    // without waiting for it to come round.
553    function colours(seconds) {
554        const p = palette(seconds);
555        return { outer: p.outer, names: PALETTES.map(function (x) { return x.name; }) };
556    }
557
558    /* ---------------------------------------------------------------- early paint
559       The logo box arrives in the prerendered HTML carrying its options as data-* attributes (see
560       HexLogo.razor). Drawing from those here, as soon as this script runs, puts the logo on screen
561       with the page instead of about a second later when the WebAssembly app has started and called
562       render() itself. Both draw from the same clock with the same options, so when the app does take
563       over the frame it shows is the one already on screen. */
564    function optionsFrom(el) {
565        const d = el.dataset, num = function (v) { const n = parseFloat(v); return isFinite(n) ? n : undefined; };
566        return {
567            ringDepth: num(d.ringDepth),
568            seamWidth: num(d.seamWidth),
569            seamColor: d.seamColor || undefined,
570            reversed: d.reversed === 'true',
571            label: d.label || undefined,
572            animate: d.animate !== 'false'
573        };
574    }
575
576    // Draw every logo box under `root` that has not been drawn yet
577    function adopt(root) {
578        if (!root || root.nodeType !== 1) return;
579        const hosts = root.matches && root.matches('[data-tg-hexlogo]') ? [root] : [];
580        const inside = root.querySelectorAll ? root.querySelectorAll('[data-tg-hexlogo]') : [];
581        for (let i = 0; i < inside.length; i++) hosts.push(inside[i]);
582        hosts.forEach(function (h) { if (!h.__tgHex) render(h, optionsFrom(h)); });
583    }
584
585    // When the WebAssembly app takes over it replaces the prerendered logo box with a fresh, empty one.
586    // A mutation observer runs before the browser next paints, so redrawing the new box here means
587    // there is no frame with the logo missing. It only needs to run while the app is starting.
588    if (window.MutationObserver && document.documentElement) {
589        const watcher = new MutationObserver(function (records) {
590            records.forEach(function (r) { r.addedNodes.forEach(adopt); });
591        });
592        watcher.observe(document.documentElement, { childList: true, subtree: true });
593        setTimeout(function () { watcher.disconnect(); }, 60000);
594    }
595
596    adopt(document.documentElement);
597    document.addEventListener('DOMContentLoaded', function () { adopt(document.documentElement); });
598
599    return { render: render, dispose: dispose, clock: clock, colours: colours };
600})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.