A Cosmos, Drawn

The code of the A Cosmos, Drawn demo, 2 files.

Run the demo All demos

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>A Cosmos, Drawn | WildflowerJS Demo</title>
    <script defer src="/js/analytics.js"></script>
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,500;1,600&display=block" rel="stylesheet">
    <!-- The framework, then Kinetic with its preview bar, then the scene. -->
    <script defer src="/js/dist/wildflower.lite.min.js"></script>
    <script defer src="/js/extensions/kinetic.wf.min.js" data-preview></script>
    <script defer src="app.js"></script>
    <style>
        * { box-sizing: border-box; }
        html, body { margin: 0; background: #0a0b10; color: #e6e6f0; font-family: system-ui, sans-serif; }
        .demo-toolbar { display: flex; justify-content: space-between; align-items: center; padding: 8px 20px; background: #12141c; border-bottom: 1px solid rgba(255,255,255,0.08); }
        .demo-toolbar a { display: flex; align-items: center; gap: 10px; text-decoration: none; color: #5fa6a6; font-size: 1rem; }
        .demo-toolbar .logo { height: 32px; }
        .demo-toolbar .brand-wf { font-weight: 520; }
        .demo-toolbar .brand-js { font-weight: 700; }
        .demo-toolbar .demo-info { font-size: 11px; color: rgba(255,255,255,0.6); text-align: right; }
        .demo-toolbar .demo-info strong { color: rgba(255,255,255,0.9); }

        [data-composition-id]:not([data-kinetic-ready]) { visibility: hidden; }
        .frame { padding: 24px; }
        #stage { position: relative; width: 1280px; height: 720px; overflow: hidden; background: #f4efe4; }
        [data-component="study"], #stage canvas { position: absolute; left: 0; top: 0; width: 1280px; height: 720px; }
    </style>
</head>
<body>
    <div class="demo-toolbar">
        <a href="/demos/">
            <img class="logo" src="/assets/wildflower_96.png" alt="WildflowerJS">
            <span><span class="brand-wf">Wildflower</span><span class="brand-js">JS</span> Demos</span>
        </a>
        <div class="demo-info"><strong>A Cosmos, Drawn</strong>: colored pencil</div>
    </div>

    <div class="frame">
        <div id="stage" data-composition-id="cosmos" data-start="0" data-duration="14" data-width="1280" data-height="720" data-fps="30">
            <div data-component="study" data-start="0" data-duration="14"><canvas></canvas></div>
        </div>
    </div>

    <div class="about">
        <p>Every stroke is laid by <code>draw()</code>, whose hand speeds up, slows down and wanders a little. Scrub, and each frame shows the ink laid by its time.</p>
    </div>
    <style>
        .about { max-width: 1280px; margin: 0 auto; padding: 0 24px 96px; color: #a9adc2; font-size: 14px; line-height: 1.55; }
        .about p { margin: 0 0 10px; }
        .about code { color: #e6e6f0; font-size: 13px; }
    </style>
</body>
</html>

app.js

wildflower.kinetic.config({ fit: true });
const { scene, draw, random } = wildflower.kinetic;

// A botanical study of a purple garden cosmos (Cosmos bipinnatus), drawn on
// in colored pencil from two reference photos: graphite planning lines,
// eight broad ray florets that overlap their neighbors, their veins fanning
// from the base with a deep magenta flush there, the yellow disc ringed with
// dark anthers, the stem with its thread-fine leaves, a ridged bud held in a
// star of bracts, and the labels written by hand.

const rnd = random('cosmos');
const TAU = Math.PI * 2;
const f1 = (n) => Math.round(n * 10) / 10;
const lineD = (p) => p.map((q, i) => (i ? 'L' : 'M') + f1(q[0]) + ' ' + f1(q[1])).join('');
const circleD = (x, y, r, n = 48) => { const p = []; for (let i = 0; i <= n; i++) { const a = (i / n) * TAU; p.push([x + r * Math.cos(a), y + r * Math.sin(a)]); } return lineD(p) + 'Z'; };
const smooth = (k) => { k = Math.max(0, Math.min(1, k)); return k * k * (3 - 2 * k); };
const curve = (p0, p1, p2, steps = 24) => { const out = []; for (let i = 0; i <= steps; i++) { const t = i / steps, s = 1 - t; out.push([s * s * p0[0] + 2 * s * t * p1[0] + t * t * p2[0], s * s * p0[1] + 2 * s * t * p1[1] + t * t * p2[1]]); } return out; };

// ---- The flower head, in polar terms around its center ----------------------

const F = [430, 300], DISC = 34, R0 = DISC - 4;
const at = (phi, r) => [F[0] + r * Math.cos(phi), F[1] + r * Math.sin(phi)];

// Each floret: its axis angle, its length to the tip, its widest half-angle,
// and its tip's lobes (n lobes between V notches, of depth `notch`).
const PETALS = [];
for (let i = 0; i < 8; i++) {
    PETALS.push({
        a: ((-96 + i * 45 + (rnd() - 0.5) * 8) * Math.PI) / 180,
        L: 168 + rnd() * 26, h: 0.38 + rnd() * 0.05,
        n: 2 + Math.floor(rnd() * 3), notch: 15 + rnd() * 8, ph: (rnd() - 0.5) * 1.2, wob: rnd() * TAU,
    });
}
// Its half-angle at radius r: narrow at the disc, fanning out to its widest.
const halfAt = (p, r) => 0.26 + (p.h - 0.26) * Math.pow(smooth((r - R0) / (0.72 * p.L - R0)), 0.8);
// Its tip's radius at angle phi: lobes between V notches.
// The lobes are uneven: their depth wanders across the tip.
const tipAt = (p, phi) => {
    const u = (phi - p.a) / p.h, depth = p.notch * (0.6 + 0.4 * Math.sin(2.3 * u + p.wob));
    return p.L - depth + depth * Math.abs(Math.sin(p.n * Math.PI * (u + 1) / 2 + p.ph)) - 12 * u * u + 4 * Math.sin(5 * u + p.wob);
};

// Each floret lies over the one before it (so its left edge hides that one's
// right), and the first lies under the last too, as florets do.
const next = (i) => PETALS[(i + 1) % 8], prev = (i) => PETALS[(i + 7) % 8];
const unwrap = (a, near) => a + TAU * Math.round((near - a) / TAU);
function leftOf(i, r) {
    const p = PETALS[i], own = p.a - halfAt(p, r);
    if (i !== 0) return { phi: own, cut: false };
    const q = prev(i), over = unwrap(q.a, p.a) + halfAt(q, r);
    return over > own ? { phi: over, cut: true } : { phi: own, cut: false };
}
function rightOf(i, r) {
    const p = PETALS[i], own = p.a + halfAt(p, r);
    if (i === 7) return { phi: own, cut: false };
    const q = next(i), over = unwrap(q.a, p.a) - halfAt(q, r);
    return over < own ? { phi: over, cut: true } : { phi: own, cut: false };
}

// A floret's visible outline, as points flagged where they lie along a
// covering floret's edge (those are not drawn: the covering floret draws them).
function outline(i) {
    const p = PETALS[i], rEdge = p.L - 18, out = [];
    for (let r = R0; r <= rEdge; r += 4) { const l = leftOf(i, r); out.push({ q: at(l.phi, r), cut: l.cut }); }
    const a = leftOf(i, rEdge).phi, b = rightOf(i, rEdge).phi;
    for (let k = 0; k <= 40; k++) { const phi = a + ((b - a) * k) / 40; out.push({ q: at(phi, Math.max(rEdge, tipAt(p, phi))), cut: false }); }
    for (let r = rEdge; r >= R0; r -= 4) { const rr = rightOf(i, r); out.push({ q: at(rr.phi, r), cut: rr.cut }); }
    return out;
}
const shapeD = (pts) => lineD(pts.map((o) => o.q)) + 'Z';
// The drawn runs: the outline split where it is cut.
function runsD(pts) {
    let d = '', run = [];
    for (const o of pts) { if (o.cut) { if (run.length > 1) d += lineD(run); run = []; } else run.push(o.q); }
    if (run.length > 1) d += lineD(run);
    return d;
}

// Veins: lines at a fixed angle from where the angle enters the visible
// floret out to near its tip, so they fan from the base. Each vein is drawn
// to its own length, and a short one in the deep flush from the base.
function veins(i) {
    const p = PETALS[i], long = [], short = [], flush = [], shadow = [];
    const a = leftOf(i, p.L - 18).phi, right = rightOf(i, p.L - 18), b = right.phi, step = 3.8 / p.L;
    for (let phi = a + step / 2; phi < b; phi += step * (0.8 + 0.4 * rnd())) {
        let r0 = null;
        for (let r = R0; r < p.L - 18; r += 2) if (leftOf(i, r).phi <= phi && rightOf(i, r).phi >= phi) { r0 = r; break; }
        if (r0 === null) continue;
        const end = tipAt(p, phi) - 3 - rnd() * 6;
        const k = rnd(), len = k < 0.6 ? end : r0 + (end - r0) * (0.45 + 0.4 * rnd());
        (k < 0.6 ? long : short).push(lineD([at(phi, r0), at(phi + 0.01 * (rnd() - 0.5), len)]));
        flush.push(lineD([at(phi, r0), at(phi, Math.min(end, r0 + 18 + rnd() * 24))]));
        // The shadow the covering floret casts, along the edge it hides.
        if (right.cut && b - phi < 0.08) shadow.push(lineD([at(phi, r0 + 10), at(phi, end - 4)]));
    }
    return { long: long.join(''), short: short.join(''), flush: flush.join(''), shadow: shadow.join('') };
}

// The disc: rings of tiny florets, and the anthers' dark tips around its middle.
const FLORETS = [], ANTHERS = [];
for (const [r, n] of [[7, 6], [15, 11], [23, 15]]) for (let i = 0; i < n; i++) {
    const a = (i / n) * TAU + r;
    FLORETS.push(circleD(F[0] + r * Math.cos(a), F[1] + r * Math.sin(a), 2.4, 10));
}
for (let i = 0; i < 22; i++) {
    const a = (i / 22) * TAU + rnd() * 0.3, r = 10 + rnd() * 16, q = [F[0] + r * Math.cos(a), F[1] + r * Math.sin(a)];
    ANTHERS.push(circleD(q[0], q[1], 1.6, 8) + lineD([q, [q[0] + 4 * Math.cos(a), q[1] + 4 * Math.sin(a)]]));
}

// ---- The stem, its leaves and a bud ---------------------------------------------

// The stem leaves the head behind its lower florets, so it is drawn from just below them.
const STEM = curve([F[0] + 18, F[1] + 196], [F[0] + 34, 560], [F[0] - 6, 730]);
const STEM_D = lineD(STEM) + lineD(STEM.map(([x, y]) => [x + 3.5, y + 1]));
const stemAt = (t) => STEM[Math.round(t * (STEM.length - 1))];

// A leaf cut thread-fine: a midrib with long thin segments off both sides,
// the longer ones forked again.
function leaf(node, a, len, bend) {
    const d = [Math.cos(a), Math.sin(a)], n = [-Math.sin(a), Math.cos(a)];
    const rib = []; for (let i = 0; i <= 20; i++) { const s = i / 20; rib.push([node[0] + d[0] * len * s + n[0] * bend * Math.sin(Math.PI * s), node[1] + d[1] * len * s + n[1] * bend * Math.sin(Math.PI * s)]); }
    let out = lineD(rib);
    for (let k = 0.16; k < 0.95; k += 0.12) {
        const p = rib[Math.round(k * 20)], l = len * 0.5 * (1 - k * 0.65);
        for (const side of [-1, 1]) {
            const b = a + side * (0.7 + (rnd() - 0.5) * 0.3), e = [p[0] + l * Math.cos(b), p[1] + l * Math.sin(b)];
            const mid = [(p[0] + e[0]) / 2 + side * 7 * Math.sin(a), (p[1] + e[1]) / 2 - side * 7 * Math.cos(a)];
            out += lineD(curve(p, mid, e, 10));
            if (l > 30) {
                const q = [p[0] + 0.45 * l * Math.cos(b), p[1] + 0.45 * l * Math.sin(b)], c = b - side * 0.55;
                out += lineD(curve(q, [q[0] + 0.3 * l * Math.cos(c + side * 0.15), q[1] + 0.3 * l * Math.sin(c + side * 0.15)], [q[0] + 0.55 * l * Math.cos(c), q[1] + 0.55 * l * Math.sin(c)], 8));
            }
        }
    }
    return out;
}
const LEAF1 = stemAt(0.22), LEAF2 = stemAt(0.6);
const LEAVES = leaf(LEAF1, (-195 * Math.PI) / 180, 160, 18) + leaf(LEAF2, (20 * Math.PI) / 180, 180, -22) + leaf(LEAF2, (-165 * Math.PI) / 180, 130, 14);

// The bud: a ridged ball on a long stalk, held in a star of narrow pointed bracts.
const BUD = [196, 440], BUD_R = 24;
const BUD_STALK = lineD(curve(LEAF2, [LEAF2[0] - 170, LEAF2[1] - 20], [BUD[0], BUD[1] + BUD_R + 6]));
const BUD_D = circleD(BUD[0], BUD[1], BUD_R, 40);
const RIDGES = [-0.6, -0.2, 0.2, 0.6].map((k) => lineD(curve([BUD[0] + k * 8, BUD[1] - BUD_R + 2], [BUD[0] + k * 30, BUD[1]], [BUD[0] + k * 10, BUD[1] + BUD_R - 2], 12))).join('');
const BRACTS = [];
for (let i = 0; i < 8; i++) {
    // Fanned from the bud's base, out to both sides and down, like a star seen edge on.
    const base = [BUD[0] + (i - 3.5) * 2, BUD[1] + BUD_R - 3];
    const dir = Math.PI - 0.15 - (i / 7) * (Math.PI - 0.3), l = (i === 0 || i === 7 ? 34 : 18) + rnd() * 12;
    BRACTS.push(lineD(curve(base, [base[0] + 0.5 * l * Math.cos(dir), base[1] + 0.5 * l * Math.sin(dir) + 4], [base[0] + l * Math.cos(dir), base[1] + l * Math.sin(dir) - 2], 8)));
}

// ---- The labels -------------------------------------------------------------------

const HAND = 'italic 600 64px "Cormorant Garamond"', NOTE = '500 30px "Cormorant Garamond"';
const RIGHT = PETALS.reduce((b, p) => (Math.cos(p.a) > Math.cos(b.a) ? p : b));
const LABELS = [
    { text: 'ray floret, notched at the tip', x: 780, y: 268, to: at(RIGHT.a, RIGHT.L - 14) },
    { text: 'disc florets', x: 780, y: 360, to: [F[0] + 18, F[1] + 12] },
    { text: 'leaf, cut thread-fine', x: 780, y: 540, to: [LEAF2[0] + 130, LEAF2[1] + 34] },
    { text: 'bud', x: 70, y: 360, to: [BUD[0] - 16, BUD[1] - 18] },
];

scene('study', {
    init() {
        draw(this.element.querySelector('canvas'), {
            scene: this, start: 0.4, end: 11,
            inks: {
                graphite: { tool: 'pencil' },
                outline: { tool: 'pencil', color: '#7a2477' },
                petal: { tool: 'pencil', color: '#b8399f' },
                flush: { tool: 'pencil', color: '#6e1257' },
                ochre: { tool: 'pencil', color: '#e0a514' },
                orange: { tool: 'pencil', color: '#c9641a' },
                umber: { tool: 'pencil', color: '#3b2a20' },
                green: { tool: 'pencil', color: '#3f7228' },
                sap: { tool: 'pencil', color: '#8fb552' },
                bud: { tool: 'pencil', color: '#5d2a5e' },
            },
            strokes: (s) => {
                // Planning lines: the head's circle, its axes, the stem's line.
                s.construct({ d: circleD(F[0], F[1], 205, 40), ink: 'graphite' })
                 .construct({ points: [[F[0] - 230, F[1]], [F[0] + 230, F[1]]], ink: 'graphite' })
                 .construct({ points: [[F[0], F[1] - 230], STEM[STEM.length - 1]], ink: 'graphite' });

                // The florets: outlined, the veins laid in, the flush at the base.
                const shapes = PETALS.map((p, i) => outline(i));
                shapes.forEach((pts) => s.contour({ d: runsD(pts), ink: 'outline', group: 'petals' }));
                PETALS.forEach((p, i) => {
                    const v = veins(i);
                    s.contour({ d: v.long, ink: 'petal', twice: false, pressure: 0.5, group: 'petals' });
                    if (v.short) s.contour({ d: v.short, ink: 'petal', twice: false, pressure: 0.4, group: 'petals' });
                    if (v.shadow) s.contour({ d: v.shadow, ink: 'petal', twice: false, pressure: 0.9, group: 'petals' });
                    s.contour({ d: v.flush, ink: 'flush', twice: false, pressure: 0.8, group: 'petals' });
                });
                // The outlines gone over once more, firm.
                shapes.forEach((pts) => s.accent({ d: runsD(pts), ink: 'outline', pressure: 0.8, group: 'petals' }));

                // The disc.
                s.contour({ d: circleD(F[0], F[1], DISC, 36), ink: 'ochre', group: 'disc' })
                 .hatch({ clip: { circle: [F[0], F[1], DISC - 2] }, ink: 'ochre', angle: -40, spacing: 4, cross: true, pressure: 1.1, group: 'disc' })
                 .accent({ d: FLORETS.join(''), ink: 'orange', pressure: 0.9, group: 'disc' })
                 .accent({ d: ANTHERS.join(''), ink: 'umber', pressure: 1.3, group: 'disc' });

                // The stem, the leaves, the bud.
                s.contour({ d: STEM_D, ink: 'green', twice: false, group: 'stem' })
                 .contour({ d: LEAVES, ink: 'green', twice: false, pressure: 1.25, group: 'stem' })
                 .contour({ d: BUD_STALK, ink: 'green', twice: false, group: 'stem' })
                 .contour({ d: BUD_D, ink: 'bud', group: 'stem' })
                 .hatch({ clip: { circle: [BUD[0], BUD[1], BUD_R - 1] }, ink: 'petal', angle: -70, spacing: 3, pressure: 1.1, group: 'stem' })
                 .contour({ d: RIDGES, ink: 'bud', twice: false, pressure: 0.9, group: 'stem' })
                 .accent({ d: BRACTS.join(''), ink: 'sap', group: 'stem' });

                // The name and the notes, each with its leader.
                s.pause(0.3)
                 .text({ text: 'Cosmos bipinnatus', font: HAND, x: 780, y: 140, ink: 'graphite', letter: 0.09, group: 'labels' })
                 .text({ text: 'garden cosmos, purple form', font: NOTE, x: 784, y: 186, ink: 'outline', letter: 0.06, group: 'labels' });
                for (const l of LABELS) {
                    const from = l.x > F[0] ? [l.x - 12, l.y - 10] : [l.x + 56, l.y - 10];
                    s.accent({ points: [from, l.to], ink: 'graphite', pressure: 0.55, group: 'labels' })
                     .text({ text: l.text, font: NOTE, x: l.x, y: l.y, ink: 'graphite', letter: 0.05, group: 'labels' });
                }
                return s;
            },
        });
    },
});