openvibers/openvibe.shared mark.js
mark.js
| 1 | ; |
| 2 | // ═══════════════════════════════════════════════════════════════ |
| 3 | // openvibe-shared/mark — the OpenVibe mark as static SVG, for servers, icons, share cards and anyone else. |
| 4 | // |
| 5 | // The mark is the Pair: a ring O and a round V, the V with a gap of the ground around it where it crosses the ring |
| 6 | // (the owner, 2026-10-31: "Pair, halo … this should be our new logo"). The gap is a mask, not a painted halo, so the |
| 7 | // mark sits on any ground: a photo, a gradient, a light or a dark theme. |
| 8 | // |
| 9 | // Geometry, in a 100-unit box with the mark centred on (50, 50): the V is exactly as tall as the O (its caps meet the |
| 10 | // O's top and bottom), so it sits level beside a word and centred in a tile. Two forms: |
| 11 | // pair the mark: ring 20 + stroke 13, the V's stroke 13. |
| 12 | // heavy for 22 px wide and under (favicons, buttons): ring 18 + stroke 18, the V's stroke 17. |
| 13 | // ov-mark.js (the browser drop-in with its states and motion) draws the same numbers; test/mark.test.js checks it. |
| 14 | // |
| 15 | // geo(form) → { cx, cy, r, sw, R, vsw, halo, A, T, B, ring, box, wideBox } |
| 16 | // shapes(form) → the V's two strokes for each shape it takes: v, ok (a tick), no (a cross), |
| 17 | // err (an exclamation mark), tip (folded into its point, for the intro) |
| 18 | // art({ form, o, v, id, shape, depth }) |
| 19 | // → { defs, body }: the mark's <defs> and drawing, to place inside another SVG |
| 20 | // svg({ form, tone, wide, size, title, id, depth }) |
| 21 | // → a standalone SVG. tone: 'brand' (blue O, violet V), 'ink' (currentColor), |
| 22 | // 'white', or { o, v }. wide: the box hugs the mark (92 × 56) instead of a square. |
| 23 | // depth (4.66.0; on by default for the brand tone): each stroke runs from a lighter |
| 24 | // to a deeper tone of its colour, as ov-mark.js draws it. Hex colours only; the |
| 25 | // one-colour tones stay flat. |
| 26 | // COLORS → { o: '#4f7cff', v: '#8b5cf6' } |
| 27 | // ═══════════════════════════════════════════════════════════════ |
| 28 | |
| 29 | const COLORS = Object.freeze({ o: '#4f7cff', v: '#8b5cf6' }); |
| 30 | const f = (v) => +v.toFixed(2); |
| 31 | |
| 32 | /** The Pair: ring radius r and stroke sw, the V's stroke vsw; the V's left cap a, tip t and right cap b, as multiples of the ring's outer radius from its centre. */ |
| 33 | // The ground around the V where it crosses the O: the wide gap (the owner's pick, 2026-11-10; it was 7). ov-mark.js |
| 34 | // has the same number (test/mark.test.js). |
| 35 | const HALO = 14; |
| 36 | function pair(r, sw, vsw, a = 0.86, t = 1.5, b = 2.16) { |
| 37 | const R = r + sw / 2, right = b * R + vsw / 2, cx = 50 - (right - R) / 2, cy = 50; |
| 38 | const A = [f(cx + a * R), f(cy - R + vsw / 2)], T = [f(cx + t * R), f(cy + R - vsw / 2)], B = [f(cx + b * R), f(cy - R + vsw / 2)]; |
| 39 | // The ring as a path from its top, clockwise: a page load draws it as progress (pathLength 100). |
| 40 | const ring = `M${f(cx)} ${f(cy - r)}A${r} ${r} 0 1 1 ${f(cx)} ${f(cy + r)}A${r} ${r} 0 1 1 ${f(cx)} ${f(cy - r)}`; |
| 41 | const x0 = f(cx - R), x1 = f(B[0] + vsw / 2), pad = 2; |
| 42 | const w = f(x1 - x0 + 2 * pad); |
| 43 | return { |
| 44 | cx: f(cx), cy, r, sw, R: f(R), vsw, halo: vsw + HALO, A, T, B, ring, |
| 45 | // A square box centred on the mark, and a wide one that hugs it. |
| 46 | box: `${f(50 - w / 2)} ${f(50 - w / 2)} ${w} ${w}`, |
| 47 | wideBox: `${f(50 - w / 2)} ${f(50 - R - pad)} ${w} ${f(2 * R + 2 * pad)}`, |
| 48 | }; |
| 49 | } |
| 50 | const FORMS = Object.freeze({ pair: pair(20, 13, 13), heavy: pair(18, 18, 17) }); |
| 51 | const geo = (form) => FORMS[form === 'heavy' || form === 'solid' ? 'heavy' : 'pair']; |
| 52 | |
| 53 | /** The V's two strokes, each from its first point, for every shape it takes: [x1, y1, x2, y2, x3, y3, x4, y4]. */ |
| 54 | function shapes(form) { |
| 55 | const g = geo(form), [A, T, B] = [g.A, g.T, g.B]; |
| 56 | const w = B[0] - A[0], h = T[1] - A[1], cx = A[0] + w / 2, cy = A[1] + h * 0.55, s = Math.min(w, h) * 0.42; |
| 57 | const tick = [A[0] + w * 0.46, T[1]]; |
| 58 | return Object.fromEntries(Object.entries({ |
| 59 | v: [T[0], T[1], A[0], A[1], T[0], T[1], B[0], B[1]], |
| 60 | tip: [T[0], T[1], T[0], T[1], T[0], T[1], T[0], T[1]], |
| 61 | ok: [tick[0], tick[1], A[0] + w * 0.16, A[1] + h * 0.52, tick[0], tick[1], B[0] + w * 0.06, A[1]], |
| 62 | no: [cx - s + w * 0.1, cy - s, cx + s + w * 0.1, cy + s, cx - s + w * 0.1, cy + s, cx + s + w * 0.1, cy - s], |
| 63 | err: [cx, A[1], cx, T[1] - g.vsw * 1.28, cx, T[1], cx, T[1] + 0.01], |
| 64 | }).map(([k, p]) => [k, p.map(f)])); |
| 65 | } |
| 66 | |
| 67 | const stroke = (p, i) => `M${p[i]} ${p[i + 1]}L${p[i + 2]} ${p[i + 3]}`; |
| 68 | const HEX = /^#[0-9a-f]{6}$/i; |
| 69 | /** hex mixed toward `to` by t (0..1): ov-mark.js's color-mix, worked out for files that cannot run CSS. */ |
| 70 | function mix(hex, to, t) { |
| 71 | const a = parseInt(hex.slice(1), 16), b = parseInt(to.slice(1), 16); |
| 72 | return `#${[16, 8, 0].map((sh) => Math.round(((a >> sh) & 255) * (1 - t) + ((b >> sh) & 255) * t).toString(16).padStart(2, '0')).join('')}`; |
| 73 | } |
| 74 | /** The light-to-deep tones of each stroke: the same mixes as ov-mark.js (O 68% / 84%, V 70% / 86%). */ |
| 75 | const tones = (o, v) => ({ o: [mix(o, '#ffffff', 0.32), mix(o, '#000000', 0.16)], v: [mix(v, '#ffffff', 0.30), mix(v, '#000000', 0.14)] }); |
| 76 | |
| 77 | /** The mark's defs and drawing, to place inside another SVG. ids are prefixed with `id` (unique per document). */ |
| 78 | function art({ form = 'pair', o = COLORS.o, v = COLORS.v, id = 'ovm', shape = 'v', depth = false } = {}) { |
| 79 | const g = geo(form), p = shapes(form)[shape] || shapes(form).v; |
| 80 | let defs = `<mask id="${id}h" maskUnits="userSpaceOnUse" x="-20" y="-20" width="140" height="140"><rect x="-20" y="-20" width="140" height="140" fill="#fff"/>` |
| 81 | + `<path d="${stroke(p, 0)}${stroke(p, 4)}" fill="none" stroke="#000" stroke-width="${g.halo}" stroke-linecap="round"/></mask>`; |
| 82 | let os = o, vs = v; |
| 83 | if (depth && HEX.test(o) && HEX.test(v)) { |
| 84 | // In user space: a straight stroke (the "!" shape) has no box for a bounding-box gradient to span. |
| 85 | const t = tones(o, v); |
| 86 | defs += `<linearGradient id="${id}go" gradientUnits="userSpaceOnUse" x1="${f(g.cx - g.R)}" y1="${f(g.cy - g.R)}" x2="${f(g.cx + g.R)}" y2="${f(g.cy + g.R)}"><stop offset="0" stop-color="${t.o[0]}"/><stop offset="1" stop-color="${t.o[1]}"/></linearGradient>` |
| 87 | + `<linearGradient id="${id}gv" gradientUnits="userSpaceOnUse" x1="0" y1="${g.A[1]}" x2="0" y2="${g.T[1]}"><stop offset="0" stop-color="${t.v[0]}"/><stop offset="1" stop-color="${t.v[1]}"/></linearGradient>`; |
| 88 | os = `url(#${id}go)`; vs = `url(#${id}gv)`; |
| 89 | } |
| 90 | const body = `<circle cx="${g.cx}" cy="${g.cy}" r="${g.r}" fill="none" stroke="${os}" stroke-width="${g.sw}" mask="url(#${id}h)"/>` |
| 91 | + `<path d="${stroke(p, 0)}${stroke(p, 4)}" fill="none" stroke="${vs}" stroke-width="${g.vsw}" stroke-linecap="round" stroke-linejoin="round"/>`; |
| 92 | return { defs, body }; |
| 93 | } |
| 94 | |
| 95 | const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); |
| 96 | |
| 97 | /** A standalone SVG of the mark. */ |
| 98 | function svg({ form = 'pair', tone = 'brand', wide = false, size = null, title = 'OpenVibe', id = 'ovm', depth = tone === 'brand' } = {}) { |
| 99 | const c = typeof tone === 'object' && tone ? tone |
| 100 | : tone === 'ink' ? { o: 'currentColor', v: 'currentColor' } |
| 101 | : tone === 'white' ? { o: '#fff', v: '#fff' } : COLORS; |
| 102 | const g = geo(form), box = wide ? g.wideBox : g.box; |
| 103 | const [, , bw, bh] = box.split(' ').map(Number); |
| 104 | const dims = size ? ` width="${f(wide ? size * bw / bh : size)}" height="${size}"` : ''; |
| 105 | const { defs, body } = art({ form, o: c.o, v: c.v, id, depth }); |
| 106 | return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${box}"${dims} role="img" aria-label="${esc(title)}"><title>${esc(title)}</title><defs>${defs}</defs>${body}</svg>`; |
| 107 | } |
| 108 | |
| 109 | module.exports = { COLORS, FORMS, geo, shapes, art, svg, tones }; |
| 110 |