master
Colby Knox Merge PR #181: Add circuit generators that follow rendered text contours b00fdcb 18d ago
import { generateCircuitTile, tileSvg } from '../vendor/circuit-trace-generators/circuit-background.js';
import { generateCircuitField, pathData } from '../vendor/circuit-trace-generators/circuit-geometry-v9.js';

// Work in hero coordinates, never viewport pixels: a thumbnail and a PNG use
// the same routes. Only the composition's measured keepouts change geometry.
export const WIDTH = 1000, HEIGHT = 625;
export const DEFAULTS = Object.freeze({ density: 0.8, scale: 1.4, strength: 0.22, clearance: 8 });
const attr = value => String(value).replace(/[&<>"']/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&apos;' }[c]));
const dataUrl = svg => 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);

// Range supplies the browser's actual line breaks and positions, including
// inline accents, cap trimming and overflowing words. Canvas supplies ink at
// those positions, using the already loaded font (not the layout column).
function paintTextInk(ctx, el, origin, sx, sy, clearance) {
  const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
  const range = document.createRange();
  const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
  for (let node; (node = walker.nextNode());) {
    const style = getComputedStyle(node.parentElement);
    if (style.visibility === 'hidden' || Number(style.opacity) === 0) continue;
    ctx.save();
    ctx.scale(sx, sy);
    ctx.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
    ctx.fontKerning = style.fontKerning;
    ctx.fontStretch = style.fontStretch;
    ctx.fontVariantCaps = style.fontVariantCaps;
    ctx.letterSpacing = style.letterSpacing === 'normal' ? '0px' : style.letterSpacing;
    ctx.wordSpacing = style.wordSpacing;
    ctx.direction = style.direction;
    ctx.textAlign = style.direction === 'rtl' ? 'right' : 'left';
    ctx.textBaseline = 'alphabetic';
    ctx.lineJoin = 'round';
    ctx.lineWidth = 2 * clearance / sx;
    let line;
    const flush = () => {
      if (!line || !line.text.trim()) return;
      const text = style.textTransform === 'uppercase' ? line.text.toUpperCase()
        : style.textTransform === 'lowercase' ? line.text.toLowerCase() : line.text;
      const metrics = ctx.measureText(text);
      // Range's inline font box spans fontBoundingBoxAscent/Descent, even
      // with text-box-trim. The alphabetic baseline is inside that box.
      const fontHeight = metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent;
      const y = line.top - origin.top + (line.height - fontHeight) / 2 + metrics.fontBoundingBoxAscent;
      const x = (style.direction === 'rtl' ? line.right : line.left) - origin.left;
      if (clearance > 0) ctx.strokeText(text, x, y);
      ctx.fillText(text, x, y);
    };
    for (const { segment, index } of segmenter.segment(node.data)) {
      range.setStart(node, index); range.setEnd(node, index + segment.length);
      const rect = range.getBoundingClientRect();
      if (!rect.width || !rect.height) continue;
      if (line && Math.abs(line.top - rect.top) > 1) { flush(); line = null; }
      if (!line) line = { text:'', top:rect.top, height:rect.height, left:rect.left, right:rect.right };
      line.text += segment;
      line.left = Math.min(line.left, rect.left); line.right = Math.max(line.right, rect.right);
    }
    flush(); ctx.restore();
  }
}

// Convert the painted ink into disjoint scanline polygons on a two-unit grid.
// Merge equal spans vertically. This keeps curved/stepped letter contours and
// counters without a font parser, external service, or thousands of obstacles.
function inkRings(ctx) {
  const step = 2, pixels = ctx.getImageData(0, 0, WIDTH, HEIGHT).data;
  const rectangles = []; let active = new Map();
  for (let y = 0; y < HEIGHT; y += step) {
    const next = new Map(); let start = -1;
    for (let x = 0; x <= WIDTH; x += step) {
      let ink = false;
      for (let dy = 0; !ink && dy < step && y + dy < HEIGHT; dy++) {
        for (let dx = 0; dx < step && x + dx < WIDTH; dx++) {
          if (pixels[((y + dy) * WIDTH + x + dx) * 4 + 3] > 16) { ink = true; break; }
        }
      }
      if (ink && start < 0) start = x;
      if (!ink && start >= 0) {
        const key = `${start}:${x}`, rect = active.get(key) || { x:start, y, right:x, bottom:y };
        rect.bottom = Math.min(HEIGHT, y + step); next.set(key, rect);
        if (!active.has(key)) rectangles.push(rect);
        start = -1;
      }
    }
    active = next;
  }
  return rectangles.map(r => [[r.x,r.y],[r.right,r.y],[r.right,r.bottom],[r.x,r.bottom],[r.x,r.y]]);
}

export function keepouts(hero, clearance = DEFAULTS.clearance) {
  const base = hero.getBoundingClientRect();
  const sx = WIDTH / (base.width - hero.clientLeft * 2);
  const sy = HEIGHT / (base.height - hero.clientTop * 2);
  const boxes = [];
  const visible = el => el.getClientRects().length && getComputedStyle(el).visibility !== 'hidden';
  const canvas = document.createElement('canvas'); canvas.width = WIDTH; canvas.height = HEIGHT;
  const ctx = canvas.getContext('2d', { willReadFrequently:true });
  const origin = { left:base.left + hero.clientLeft, top:base.top + hero.clientTop };
  for (const selector of ['.h-title', '.h-sub', '.h-desc', '.c-name', '.c-mpn', '.c-spec', '.slug-br']) {
    const el = hero.querySelector(selector);
    if (el && visible(el)) paintTextInk(ctx, el, origin, sx, sy, clearance);
  }
  const rings = inkRings(ctx);
  if (rings.length) boxes.push({ id:'text-ink', type:'path', space:'world', rings });
  // These objects really are solid shapes. Never include a text container or
  // the simulated homepage overlay, which adds its own scrim at display time.
  const selectors = ['.window', '.comp-side', '.pill-tr', '.c-chips .c-family', '.wordmark:not(.noadom) .wmark'];
  for (const selector of selectors) {
    for (const el of hero.querySelectorAll(selector)) {
      if (!visible(el)) continue;
      const r = el.getBoundingClientRect();
      if (r.width < 1 || r.height < 1) continue;
      // Conservative 4-unit grid removes subpixel differences between surfaces.
      const x = Math.max(0, Math.floor(((r.left - base.left - hero.clientLeft) * sx - clearance) / 4) * 4);
      const y = Math.max(0, Math.floor(((r.top - base.top - hero.clientTop) * sy - clearance) / 4) * 4);
      const right = Math.min(WIDTH, Math.ceil(((r.right - base.left - hero.clientLeft) * sx + clearance) / 4) * 4);
      const bottom = Math.min(HEIGHT, Math.ceil(((r.bottom - base.top - hero.clientTop) * sy + clearance) / 4) * 4);
      if (right > x && bottom > y) boxes.push({ id: selector.slice(1), x, y, width: right - x, height: bottom - y });
    }
  }
  return boxes;
}

export function composeTraces({ theme, seed, settings = {}, boxes = [], color = '#8b949e', background = '#0d1117' }) {
  const config = { ...DEFAULTS, ...settings };
  const { density, scale, strength } = config;
  let art, defs = '', count;
  if (theme === 'circuit-tile') {
    const tile = generateCircuitTile({ seed: String(seed), density });
    const svg = tileSvg(tile, { traceColor: color, holeColor: background });
    const w = tile.width * scale, h = tile.height * scale;
    defs = `<pattern id="tile" width="${w}" height="${h}" patternUnits="userSpaceOnUse"><image width="${w}" height="${h}" href="${attr(dataUrl(svg))}"/></pattern>`;
    art = `<rect width="${WIDTH}" height="${HEIGHT}" fill="url(#tile)"/>`;
    count = tile.traces.length;
  } else {
    const field = generateCircuitField({
      seed: String(seed), width: WIDTH + 24, height: HEIGHT + 24,
      featureScale: scale, fill: 0.668 * density, obstaclePreset: 'none',
      obstacleMode: 'avoid', obstacleClearance: 0,
      obstacles: boxes.map(b => b.rings ? b : ({ ...b, type: 'rect', space: 'world', x: b.x + b.width / 2, y: b.y + b.height / 2 })),
    });
    art = field.traces.map(t => `<path d="${pathData(t.points)}"/>`
      + [t.points[0], t.points[t.points.length - 1]].map(([x,y]) => `<circle cx="${x}" cy="${y}" r="${2.55 * scale}" stroke-width="${1.58 * scale}"/>`).join('')).join('');
    art = `<g fill="none" stroke="${attr(color)}" stroke-width="${1.58 * scale}" stroke-linecap="butt" stroke-linejoin="miter">${art}</g>`;
    count = field.traces.length;
  }
  // Tile geometry remains seamless; soften it around the same keepouts the
  // field router avoids. Masked SVG is self-contained inside one cloned img.
  const holes = boxes.map(b => b.rings
    ? `<path d="${b.rings.map(r => pathData(r) + 'Z').join('')}" fill="black"/>`
    : `<rect x="${b.x}" y="${b.y}" width="${b.width}" height="${b.height}" rx="8" fill="black"/>`).join('');
  if (theme === 'circuit-tile') defs += `<filter id="soft" x="-10%" y="-10%" width="120%" height="120%"><feGaussianBlur stdDeviation="3"/></filter><mask id="quiet" maskUnits="userSpaceOnUse" x="0" y="0" width="${WIDTH}" height="${HEIGHT}"><rect width="${WIDTH}" height="${HEIGHT}" fill="white"/><g filter="url(#soft)">${holes}</g>${holes}</mask>`;
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}"><defs>${defs}</defs><g opacity="${strength}"${theme === 'circuit-tile' ? ' mask="url(#quiet)"' : ''}>${art}</g></svg>`;
  return { src: dataUrl(svg), count };
}