Перейти к содержимому

Иллюстрации

Терминал

Изометрическая иллюстрация терминального окна с интерактивными строками и пружинной анимацией.


Терминал

Terminal — живая SVG-иллюстрация изометрического терминального окна. Наведите курсор на строки, чтобы увидеть подъём и подсветку соседних элементов.

Интерактивное демо

rest

Пропсы

Акцент
Распространение
3
Подсказка
<script lang="ts">
  import { onMount } from 'svelte';

  type Pt = [number, number];
  type Spring = { x: number; t: number; v: number; e: number };
  type Prism = { b: SVGElement; c: SVGElement; f: SVGElement; i: SVGElement };
  type Seg = { x0: number; x1: number; p: Prism };
  type Row = { j: number; segs: Seg[]; sp: Spring; drawn: string };
  type Api = { setSpread: (v: number) => void };

  /** Цвет подсвеченной строки, шеврона и курсора. */
  export let accent = '#f43f5e';
  /** Насколько широко распространяется подъём, в строках. Можно биндить. */
  export let spread = 3;
  export let width = 'min(88vw, 420px)';
  export let showControls = true;
  export let label = 'Terminal window as an isometric slab with scrolling lines';

  const N = 24, V = 7, G = 11, T = 2.1, LH = 1.8, LIFT = 5.5, HB = 3.4, SLAB = 3;
  const W = 150, TB = 17, PB = 17, PAD = 11, IN = 2.5, FIRST = 37, CH = 3.9;
  const VB = PB, VA = PB + V * G, H = VA + TB, E = 4;
  const K = 2.1, C = 0.7071, OX = 70, OY = 360;
  const NS = 'http://www.w3.org/2000/svg';
  /** История, от старых строк к новым: отступ в ячейках, затем длина каждого токена в ячейках. */
  const HIST = [
    '0 5 12', '2 7 3 9', '2 14', '4 6 10', '4 18', '2 3', '0 2', '0 2 6 8',
    '0 26', '0 20', '0 11 4', '2 9 12', '2 5 3 7', '4 16', '4 8 8', '2 4',
    '0 3 14', '0 24', '0 30', '0 17', '2 6 11', '2 13', '0 9 5', '0 4 7'
  ];
  const REST_TOP = N - V - 2.6, REST_LINE = 18, REST_R = 2;

  const clamp = (x: number, a: number, b: number): number => Math.min(b, Math.max(a, x));
  const smooth = (t: number): number => t * t * (3 - 2 * t);
  /** Система координат плиты: u поперёк, v вверх, w из плоскости лица. */
  const P2 = (u: number, v: number, w: number): Pt => [OX + (u + w) * C * K, OY + ((w - u) * C * 0.5 - v) * K];
  const onFace = (sx: number, sy: number, w: number): Pt => {
    const u = (sx - OX) / (C * K) - w;
    return [u, (w - u) * C * 0.5 - (sy - OY) / K];
  };
  function rr(x0: number, y0: number, x1: number, y1: number, r: number, S = 8): Pt[] {
    const p: Pt[] = [];
    const corners: [number, number, number][] = [
      [x1 - r, y0 + r, -90],
      [x1 - r, y1 - r, 0],
      [x0 + r, y1 - r, 90],
      [x0 + r, y0 + r, 180]
    ];
    corners.forEach((c) => {
      for (let i = 0; i <= S; i++) {
        const t = ((c[2] + (90 * i) / S) * Math.PI) / 180;
        p.push([c[0] + r * Math.cos(t), c[1] + r * Math.sin(t)]);
      }
    });
    return p;
  }
  const pd = (pts: Pt[], close: boolean): string =>
    pts.map((q, i) => (i ? 'L' : 'M') + q[0].toFixed(1) + ',' + q[1].toFixed(1)).join('') + (close ? 'Z' : '');
  const cut = (ring: Pt[]): Pt[] => ring.map((q): Pt => [q[0], clamp(q[1], VB, VA)]);
  const spring = (x: number, e: number): Spring => ({ x, t: x, v: 0, e });
  function step(sp: Spring, dt: number): boolean {
    const k = 170, c = 24, n = Math.max(1, Math.ceil(dt / 0.008)), h = dt / n;
    for (let i = 0; i < n; i++) {
      sp.v += ((sp.t - sp.x) * k - sp.v * c) * h;
      sp.x += sp.v * h;
    }
    if (Math.abs(sp.t - sp.x) < sp.e && Math.abs(sp.v) < sp.e * 4) {
      sp.x = sp.t;
      sp.v = 0;
      return false;
    }
    return true;
  }
  /** Очистка узла вынесена в функцию: правило svelte/no-dom-manipulating ругается на прямой вызов у bind:this-элемента. */
  const clearNode = (el: Element): void => el.replaceChildren();

  let svg: SVGSVGElement;
  let readout = 'rest';
  let api: Api | null = null;
  $: if (api) api.setSpread(spread);

  onMount(() => {
    const mk = (n: string, a: Record<string, string>, p?: Element): SVGElement => {
      const e = document.createElementNS(NS, n);
      for (const k in a) e.setAttribute(k, a[k]);
      (p ?? svg).appendChild(e);
      return e;
    };
    /** Призма: задний контур, рёбра силуэта, непрозрачная передняя грань, сгиб. */
    const mkP = (par: Element): Prism => {
      const g = mk('g', {}, par);
      return { b: mk('path', { class: 'n' }, g), c: mk('path', { class: 'n' }, g), f: mk('path', { class: 'f' }, g), i: mk('path', { class: 'lo' }, g) };
    };
    function setP(p: Prism, ring: Pt[], inner: Pt[] | null, w0: number, w1: number): void {
      const f = ring.map((q) => P2(q[0], q[1], w1));
      const b = ring.map((q) => P2(q[0], q[1], w0));
      const n = f.length;
      const dx = b[0][0] - f[0][0], dy = b[0][1] - f[0][1];
      const cs: number[] = [];
      let cn = '';
      for (let i = 0; i < n; i++) {
        const a = f[i], c = f[(i + 1) % n];
        cs.push((c[0] - a[0]) * dy - (c[1] - a[1]) * dx);
      }
      // рёбра, где контур идёт параллельно направлению глубины
      for (let i = 0; i < n; i++) {
        if (cs[i] * cs[(i + n - 1) % n] < 0) cn += 'M' + f[i][0].toFixed(1) + ',' + f[i][1].toFixed(1) + 'L' + b[i][0].toFixed(1) + ',' + b[i][1].toFixed(1);
      }
      p.b.setAttribute('d', pd(b, true));
      p.c.setAttribute('d', cn);
      p.f.setAttribute('d', pd(f, true));
      p.i.setAttribute('d', inner ? pd(inner.map((q) => P2(q[0], q[1], w1)), true) : '');
    }
    const clr = (p: Prism): void => {
      p.b.setAttribute('d', '');
      p.c.setAttribute('d', '');
      p.f.setAttribute('d', '');
      p.i.setAttribute('d', '');
    };

    const g = mk('g', {});
    const base = mkP(g);
    setP(base, rr(0, 0, W, H, 9), rr(2, 2, W - 2, H - 2, 7), -SLAB, 0);

    const rows: Row[] = HIST.map((str, j) => {
      const a = str.split(' ').map(Number), segs: Seg[] = [];
      let pos = a[0];
      for (let q = 1; q < a.length; q++) {
        const x0 = PAD + pos * CH;
        segs.push({ x0, x1: x0 + a[q] * CH - 1.4, p: mkP(g) });
        pos += a[q] + 1;
      }
      return { j, segs, sp: spring(0, 0.01), drawn: '' };
    });

    // строка ввода, шеврон, курсор; затем заголовок окна и кнопки, они рисуются поверх строк
    const prompt = mkP(g);
    setP(prompt, rr(IN, IN, W - IN, VB, 6, 5), rr(IN + 1.4, IN + 1.4, W - IN - 1.4, VB - 1.4, 4.6, 5), 0, HB);
    const cy = (IN + VB) / 2;
    const chev = mk('path', { class: 'n' }, g);
    const chevPts: Pt[] = [[0, 6.2], [8.5, 0], [0, -6.2], [0, -3.1], [4.25, 0], [0, 3.1]];
    chev.setAttribute('d', pd(chevPts.map((q) => P2(PAD + q[0], cy + q[1], HB)), true));
    const cursor = mkP(g);
    setP(cursor, rr(PAD + 13, cy - 5.2, PAD + 13 + CH * 1.5, cy + 5.2, 1.2, 3), rr(PAD + 13.7, cy - 4.5, PAD + 13 + CH * 1.5 - 0.7, cy + 4.5, 0.8, 3), HB, HB + 2.8);
    const title = mkP(g);
    setP(title, rr(IN, VA, W - IN, H - IN, 6, 5), rr(IN + 1.4, VA + 1.4, W - IN - 1.4, H - IN - 1.4, 4.6, 5), 0, HB);
    for (let i = 0; i < 3; i++) {
      const cx = PAD + i * 8.5, cv = (VA + H - IN) / 2;
      setP(mkP(g), rr(cx - 2.8, cv - 2.8, cx + 2.8, cv + 2.8, 2.8, 5), null, HB, HB + 1.3);
    }

    /** Строка j при прокрутке окна до `top`: обрезается по окну, приподнимается слабее у краёв. */
    function drawRow(rw: Row, top: number): void {
      const key = `${top}|${rw.sp.x}`;
      if (key === rw.drawn) return;
      rw.drawn = key;
      const vc = VA - (rw.j - top + 0.5) * G, v0 = Math.max(vc - T, VB), v1 = Math.min(vc + T, VA);
      const env = smooth(clamp((VA - vc - T) / E, 0, 1)) * smooth(clamp((vc - T - VB) / E, 0, 1));
      const w0 = rw.sp.x * env;
      for (const sg of rw.segs) {
        if (v1 - v0 < 0.05) { clr(sg.p); continue; }
        setP(sg.p, cut(rr(sg.x0, vc - T, sg.x1, vc + T, T, 4)), cut(rr(sg.x0 + 0.7, vc - T + 0.7, sg.x1 - 0.7, vc + T - 0.7, T - 0.7, 4)), w0, w0 + LH);
      }
    }

    let lit: SVGElement[] = [];
    const light = (els: SVGElement[]): void => {
      lit.forEach((e) => e.classList.remove('hi'));
      lit = els;
      lit.forEach((e) => e.classList.add('hi'));
    };

    const top = spring(REST_TOP, 0.002);
    let R = spread;
    let over: 'prompt' | number | null = null;

    /** Где указатель: "prompt", глубина по строкам от 0 до 1 или null вне окна. */
    function hit(sx: number, sy: number): 'prompt' | number | null {
      const qb = onFace(sx, sy, HB), inU = (q: Pt) => q[0] > IN && q[0] < W - IN;
      if (inU(qb) && qb[1] > IN && qb[1] < VB) return 'prompt';
      if (inU(qb) && qb[1] > VA && qb[1] < H - IN) return 0;
      const q = onFace(sx, sy, 0);
      if (q[0] < 0 || q[0] > W || q[1] < 0 || q[1] > H) return null;
      return q[1] <= VB ? 'prompt' : clamp((VA - G / 2 - q[1]) / ((V - 1) * G), 0, 1);
    }
    function retarget(): void {
      let t: number, c: number, r = R;
      if (over === null) { t = REST_TOP; c = REST_LINE; r = REST_R; readout = 'rest'; }
      else if (over === 'prompt') { t = N - V; c = -1; readout = 'prompt'; }
      else {
        t = over * (N - V);
        c = clamp(Math.round(over * (N - 1)), Math.ceil(t), Math.floor(t + V - 1));
        readout = `line ${FIRST + c}`;
      }
      top.t = t;
      for (const rw of rows) rw.sp.t = c < 0 ? 0 : LIFT * clamp(1 - Math.abs(rw.j - c) / r, 0, 1);
      light(c < 0 ? [cursor.f, chev] : rows[c].segs.map((sg) => sg.p.f));
    }
    retarget();
    rows.forEach((rw) => (rw.sp.x = rw.sp.t));

    const toSvg = (e: PointerEvent): DOMPoint => {
      const pt = svg.createSVGPoint();
      pt.x = e.clientX;
      pt.y = e.clientY;
      const m = svg.getScreenCTM();
      return m ? pt.matrixTransform(m.inverse()) : pt;
    };
    const mv = (e: PointerEvent): void => { const q = toSvg(e); over = hit(q.x, q.y); retarget(); };
    const lv = (): void => { over = null; retarget(); };
    const up = (e: PointerEvent): void => { if (e.pointerType !== 'mouse') lv(); };
    svg.addEventListener('pointermove', mv);
    svg.addEventListener('pointerdown', mv);
    svg.addEventListener('pointerleave', lv);
    svg.addEventListener('pointerup', up);

    const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    let raf = 0, last = performance.now();
    const tick = (now: number): void => {
      const dt = reduce ? 1 : Math.min(0.05, (now - last) / 1000);
      last = now;
      step(top, dt);
      for (const rw of rows) { step(rw.sp, dt); drawRow(rw, top.x); }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);

    api = { setSpread: (v: number) => { R = v; if (over !== null) retarget(); } };

    return () => {
      cancelAnimationFrame(raf);
      svg.removeEventListener('pointermove', mv);
      svg.removeEventListener('pointerdown', mv);
      svg.removeEventListener('pointerleave', lv);
      svg.removeEventListener('pointerup', up);
      clearNode(svg);
      api = null;
    };
  });
</script>

<div class="hl-wrap" style="--hl-hi:{accent}">
  <svg class="hl" bind:this={svg} viewBox="0 0 380 380" role="img" aria-label={label} style="width:{width}" />
  {#if showControls}
    <div class="read">{readout}</div>
    <label class="ctl">
      lift spread
      <input type="range" min="1" max="6" step="0.1" bind:value={spread} />
      <span>{spread.toFixed(1)} lines</span>
    </label>
  {/if}
</div>

<style>
  .hl-wrap {
    /* переопределяются снаружи под тему */
    --hl-bg: #f6f4f1;
    --hl-line: #2b2f3a;
    --hl-faint: #2b2f3a66;
    --hl-txt: #6b6f7a;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 10px;
    color: var(--hl-txt);
    font: 12px ui-monospace, Menlo, Consolas, monospace;
    letter-spacing: 0.06em;
  }
  .hl {
    display: block;
    height: auto;
    max-height: 72vh;
    cursor: crosshair;
    touch-action: none;
  }
  .hl :global(*) {
    vector-effect: non-scaling-stroke;
  }
  .hl :global(.n) {
    fill: none;
    stroke: var(--hl-line);
    stroke-width: 1.1;
    stroke-linejoin: round;
    stroke-linecap: round;
  }
  .hl :global(.f) {
    fill: var(--hl-bg);
    stroke: var(--hl-line);
    stroke-width: 1.1;
    stroke-linejoin: round;
  }
  .hl :global(.lo) {
    fill: none;
    stroke: var(--hl-faint);
    stroke-width: 1;
  }
  .hl :global(.hi) {
    stroke: var(--hl-hi);
    stroke-width: 1.8;
  }
  .read { min-height: 1.2em; }
  .ctl {
    display: flex;
    gap: 10px;
    align-items: center;
  }
  .ctl input {
    width: min(56vw, 220px);
    accent-color: var(--hl-hi);
  }
</style>
<script lang="ts">
  import { onMount } from 'svelte';

  type Pt = [number, number];
  type Spring = { x: number; t: number; v: number; e: number };
  type Prism = { b: SVGElement; c: SVGElement; f: SVGElement; i: SVGElement };
  type Seg = { x0: number; x1: number; p: Prism };
  type Row = { j: number; segs: Seg[]; sp: Spring; drawn: string };
  type Api = { setSpread: (v: number) => void };

  /** Цвет подсвеченной строки, шеврона и курсора. */
  export let accent = '#f43f5e';
  /** Насколько широко распространяется подъём, в строках. Можно биндить. */
  export let spread = 3;
  export let width = 'min(88vw, 420px)';
  export let showControls = true;
  export let label = 'Terminal window as an isometric slab with scrolling lines';

  const N = 24, V = 7, G = 11, T = 2.1, LH = 1.8, LIFT = 5.5, HB = 3.4, SLAB = 3;
  const W = 150, TB = 17, PB = 17, PAD = 11, IN = 2.5, FIRST = 37, CH = 3.9;
  const VB = PB, VA = PB + V * G, H = VA + TB, E = 4;
  const K = 2.1, C = 0.7071, OX = 70, OY = 360;
  const NS = 'http://www.w3.org/2000/svg';
  /** История, от старых строк к новым: отступ в ячейках, затем длина каждого токена в ячейках. */
  const HIST = [
    '0 5 12', '2 7 3 9', '2 14', '4 6 10', '4 18', '2 3', '0 2', '0 2 6 8',
    '0 26', '0 20', '0 11 4', '2 9 12', '2 5 3 7', '4 16', '4 8 8', '2 4',
    '0 3 14', '0 24', '0 30', '0 17', '2 6 11', '2 13', '0 9 5', '0 4 7'
  ];
  const REST_TOP = N - V - 2.6, REST_LINE = 18, REST_R = 2;

  const clamp = (x: number, a: number, b: number): number => Math.min(b, Math.max(a, x));
  const smooth = (t: number): number => t * t * (3 - 2 * t);
  /** Система координат плиты: u поперёк, v вверх, w из плоскости лица. */
  const P2 = (u: number, v: number, w: number): Pt => [OX + (u + w) * C * K, OY + ((w - u) * C * 0.5 - v) * K];
  const onFace = (sx: number, sy: number, w: number): Pt => {
    const u = (sx - OX) / (C * K) - w;
    return [u, (w - u) * C * 0.5 - (sy - OY) / K];
  };
  function rr(x0: number, y0: number, x1: number, y1: number, r: number, S = 8): Pt[] {
    const p: Pt[] = [];
    const corners: [number, number, number][] = [
      [x1 - r, y0 + r, -90],
      [x1 - r, y1 - r, 0],
      [x0 + r, y1 - r, 90],
      [x0 + r, y0 + r, 180]
    ];
    corners.forEach((c) => {
      for (let i = 0; i <= S; i++) {
        const t = ((c[2] + (90 * i) / S) * Math.PI) / 180;
        p.push([c[0] + r * Math.cos(t), c[1] + r * Math.sin(t)]);
      }
    });
    return p;
  }
  const pd = (pts: Pt[], close: boolean): string =>
    pts.map((q, i) => (i ? 'L' : 'M') + q[0].toFixed(1) + ',' + q[1].toFixed(1)).join('') + (close ? 'Z' : '');
  const cut = (ring: Pt[]): Pt[] => ring.map((q): Pt => [q[0], clamp(q[1], VB, VA)]);
  const spring = (x: number, e: number): Spring => ({ x, t: x, v: 0, e });
  function step(sp: Spring, dt: number): boolean {
    const k = 170, c = 24, n = Math.max(1, Math.ceil(dt / 0.008)), h = dt / n;
    for (let i = 0; i < n; i++) {
      sp.v += ((sp.t - sp.x) * k - sp.v * c) * h;
      sp.x += sp.v * h;
    }
    if (Math.abs(sp.t - sp.x) < sp.e && Math.abs(sp.v) < sp.e * 4) {
      sp.x = sp.t;
      sp.v = 0;
      return false;
    }
    return true;
  }
  /** Очистка узла вынесена в функцию: правило svelte/no-dom-manipulating ругается на прямой вызов у bind:this-элемента. */
  const clearNode = (el: Element): void => el.replaceChildren();

  let svg: SVGSVGElement;
  let readout = 'rest';
  let api: Api | null = null;
  $: if (api) api.setSpread(spread);

  onMount(() => {
    const mk = (n: string, a: Record<string, string>, p?: Element): SVGElement => {
      const e = document.createElementNS(NS, n);
      for (const k in a) e.setAttribute(k, a[k]);
      (p ?? svg).appendChild(e);
      return e;
    };
    /** Призма: задний контур, рёбра силуэта, непрозрачная передняя грань, сгиб. */
    const mkP = (par: Element): Prism => {
      const g = mk('g', {}, par);
      return { b: mk('path', { class: 'n' }, g), c: mk('path', { class: 'n' }, g), f: mk('path', { class: 'f' }, g), i: mk('path', { class: 'lo' }, g) };
    };
    function setP(p: Prism, ring: Pt[], inner: Pt[] | null, w0: number, w1: number): void {
      const f = ring.map((q) => P2(q[0], q[1], w1));
      const b = ring.map((q) => P2(q[0], q[1], w0));
      const n = f.length;
      const dx = b[0][0] - f[0][0], dy = b[0][1] - f[0][1];
      const cs: number[] = [];
      let cn = '';
      for (let i = 0; i < n; i++) {
        const a = f[i], c = f[(i + 1) % n];
        cs.push((c[0] - a[0]) * dy - (c[1] - a[1]) * dx);
      }
      // рёбра, где контур идёт параллельно направлению глубины
      for (let i = 0; i < n; i++) {
        if (cs[i] * cs[(i + n - 1) % n] < 0) cn += 'M' + f[i][0].toFixed(1) + ',' + f[i][1].toFixed(1) + 'L' + b[i][0].toFixed(1) + ',' + b[i][1].toFixed(1);
      }
      p.b.setAttribute('d', pd(b, true));
      p.c.setAttribute('d', cn);
      p.f.setAttribute('d', pd(f, true));
      p.i.setAttribute('d', inner ? pd(inner.map((q) => P2(q[0], q[1], w1)), true) : '');
    }
    const clr = (p: Prism): void => {
      p.b.setAttribute('d', '');
      p.c.setAttribute('d', '');
      p.f.setAttribute('d', '');
      p.i.setAttribute('d', '');
    };

    const g = mk('g', {});
    const base = mkP(g);
    setP(base, rr(0, 0, W, H, 9), rr(2, 2, W - 2, H - 2, 7), -SLAB, 0);

    const rows: Row[] = HIST.map((str, j) => {
      const a = str.split(' ').map(Number), segs: Seg[] = [];
      let pos = a[0];
      for (let q = 1; q < a.length; q++) {
        const x0 = PAD + pos * CH;
        segs.push({ x0, x1: x0 + a[q] * CH - 1.4, p: mkP(g) });
        pos += a[q] + 1;
      }
      return { j, segs, sp: spring(0, 0.01), drawn: '' };
    });

    // строка ввода, шеврон, курсор; затем заголовок окна и кнопки, они рисуются поверх строк
    const prompt = mkP(g);
    setP(prompt, rr(IN, IN, W - IN, VB, 6, 5), rr(IN + 1.4, IN + 1.4, W - IN - 1.4, VB - 1.4, 4.6, 5), 0, HB);
    const cy = (IN + VB) / 2;
    const chev = mk('path', { class: 'n' }, g);
    const chevPts: Pt[] = [[0, 6.2], [8.5, 0], [0, -6.2], [0, -3.1], [4.25, 0], [0, 3.1]];
    chev.setAttribute('d', pd(chevPts.map((q) => P2(PAD + q[0], cy + q[1], HB)), true));
    const cursor = mkP(g);
    setP(cursor, rr(PAD + 13, cy - 5.2, PAD + 13 + CH * 1.5, cy + 5.2, 1.2, 3), rr(PAD + 13.7, cy - 4.5, PAD + 13 + CH * 1.5 - 0.7, cy + 4.5, 0.8, 3), HB, HB + 2.8);
    const title = mkP(g);
    setP(title, rr(IN, VA, W - IN, H - IN, 6, 5), rr(IN + 1.4, VA + 1.4, W - IN - 1.4, H - IN - 1.4, 4.6, 5), 0, HB);
    for (let i = 0; i < 3; i++) {
      const cx = PAD + i * 8.5, cv = (VA + H - IN) / 2;
      setP(mkP(g), rr(cx - 2.8, cv - 2.8, cx + 2.8, cv + 2.8, 2.8, 5), null, HB, HB + 1.3);
    }

    /** Строка j при прокрутке окна до `top`: обрезается по окну, приподнимается слабее у краёв. */
    function drawRow(rw: Row, top: number): void {
      const key = `${top}|${rw.sp.x}`;
      if (key === rw.drawn) return;
      rw.drawn = key;
      const vc = VA - (rw.j - top + 0.5) * G, v0 = Math.max(vc - T, VB), v1 = Math.min(vc + T, VA);
      const env = smooth(clamp((VA - vc - T) / E, 0, 1)) * smooth(clamp((vc - T - VB) / E, 0, 1));
      const w0 = rw.sp.x * env;
      for (const sg of rw.segs) {
        if (v1 - v0 < 0.05) { clr(sg.p); continue; }
        setP(sg.p, cut(rr(sg.x0, vc - T, sg.x1, vc + T, T, 4)), cut(rr(sg.x0 + 0.7, vc - T + 0.7, sg.x1 - 0.7, vc + T - 0.7, T - 0.7, 4)), w0, w0 + LH);
      }
    }

    let lit: SVGElement[] = [];
    const light = (els: SVGElement[]): void => {
      lit.forEach((e) => e.classList.remove('hi'));
      lit = els;
      lit.forEach((e) => e.classList.add('hi'));
    };

    const top = spring(REST_TOP, 0.002);
    let R = spread;
    let over: 'prompt' | number | null = null;

    /** Где указатель: "prompt", глубина по строкам от 0 до 1 или null вне окна. */
    function hit(sx: number, sy: number): 'prompt' | number | null {
      const qb = onFace(sx, sy, HB), inU = (q: Pt) => q[0] > IN && q[0] < W - IN;
      if (inU(qb) && qb[1] > IN && qb[1] < VB) return 'prompt';
      if (inU(qb) && qb[1] > VA && qb[1] < H - IN) return 0;
      const q = onFace(sx, sy, 0);
      if (q[0] < 0 || q[0] > W || q[1] < 0 || q[1] > H) return null;
      return q[1] <= VB ? 'prompt' : clamp((VA - G / 2 - q[1]) / ((V - 1) * G), 0, 1);
    }
    function retarget(): void {
      let t: number, c: number, r = R;
      if (over === null) { t = REST_TOP; c = REST_LINE; r = REST_R; readout = 'rest'; }
      else if (over === 'prompt') { t = N - V; c = -1; readout = 'prompt'; }
      else {
        t = over * (N - V);
        c = clamp(Math.round(over * (N - 1)), Math.ceil(t), Math.floor(t + V - 1));
        readout = `line ${FIRST + c}`;
      }
      top.t = t;
      for (const rw of rows) rw.sp.t = c < 0 ? 0 : LIFT * clamp(1 - Math.abs(rw.j - c) / r, 0, 1);
      light(c < 0 ? [cursor.f, chev] : rows[c].segs.map((sg) => sg.p.f));
    }
    retarget();
    rows.forEach((rw) => (rw.sp.x = rw.sp.t));

    const toSvg = (e: PointerEvent): DOMPoint => {
      const pt = svg.createSVGPoint();
      pt.x = e.clientX;
      pt.y = e.clientY;
      const m = svg.getScreenCTM();
      return m ? pt.matrixTransform(m.inverse()) : pt;
    };
    const mv = (e: PointerEvent): void => { const q = toSvg(e); over = hit(q.x, q.y); retarget(); };
    const lv = (): void => { over = null; retarget(); };
    const up = (e: PointerEvent): void => { if (e.pointerType !== 'mouse') lv(); };
    svg.addEventListener('pointermove', mv);
    svg.addEventListener('pointerdown', mv);
    svg.addEventListener('pointerleave', lv);
    svg.addEventListener('pointerup', up);

    const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    let raf = 0, last = performance.now();
    const tick = (now: number): void => {
      const dt = reduce ? 1 : Math.min(0.05, (now - last) / 1000);
      last = now;
      step(top, dt);
      for (const rw of rows) { step(rw.sp, dt); drawRow(rw, top.x); }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);

    api = { setSpread: (v: number) => { R = v; if (over !== null) retarget(); } };

    return () => {
      cancelAnimationFrame(raf);
      svg.removeEventListener('pointermove', mv);
      svg.removeEventListener('pointerdown', mv);
      svg.removeEventListener('pointerleave', lv);
      svg.removeEventListener('pointerup', up);
      clearNode(svg);
      api = null;
    };
  });
</script>

<div class="hl-wrap" style="--hl-hi:{accent}">
  <svg class="hl" bind:this={svg} viewBox="0 0 380 380" role="img" aria-label={label} style="width:{width}" />
  {#if showControls}
    <div class="read">{readout}</div>
    <label class="ctl">
      lift spread
      <input type="range" min="1" max="6" step="0.1" bind:value={spread} />
      <span>{spread.toFixed(1)} lines</span>
    </label>
  {/if}
</div>

<style>
  .hl-wrap {
    /* переопределяются снаружи под тему */
    --hl-bg: #f6f4f1;
    --hl-line: #2b2f3a;
    --hl-faint: #2b2f3a66;
    --hl-txt: #6b6f7a;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 10px;
    color: var(--hl-txt);
    font: 12px ui-monospace, Menlo, Consolas, monospace;
    letter-spacing: 0.06em;
  }
  .hl {
    display: block;
    height: auto;
    max-height: 72vh;
    cursor: crosshair;
    touch-action: none;
  }
  .hl :global(*) {
    vector-effect: non-scaling-stroke;
  }
  .hl :global(.n) {
    fill: none;
    stroke: var(--hl-line);
    stroke-width: 1.1;
    stroke-linejoin: round;
    stroke-linecap: round;
  }
  .hl :global(.f) {
    fill: var(--hl-bg);
    stroke: var(--hl-line);
    stroke-width: 1.1;
    stroke-linejoin: round;
  }
  .hl :global(.lo) {
    fill: none;
    stroke: var(--hl-faint);
    stroke-width: 1;
  }
  .hl :global(.hi) {
    stroke: var(--hl-hi);
    stroke-width: 1.8;
  }
  .read { min-height: 1.2em; }
  .ctl {
    display: flex;
    gap: 10px;
    align-items: center;
  }
  .ctl input {
    width: min(56vw, 220px);
    accent-color: var(--hl-hi);
  }
</style>

Использование

<script lang="ts">
	import Terminal from '$lib/components/ui-registry/Terminal.svelte';
</script>

<Terminal accent="#f43f5e" spread={3} />
<script lang="ts">
	import Terminal from '$lib/components/ui-registry/Terminal.svelte';
</script>

<Terminal accent="#f43f5e" spread={3} />

Особенности

  • SVG-иллюстрация без внешних изображений и зависимостей
  • Реакция на указатель и поддержка клавиатуры
  • Пружинная анимация строк с учётом prefers-reduced-motion

Пропсы

Пропс Тип По умолчанию Описание
accent string '#f43f5e' Цвет подсветки строки, шеврона и курсора
spread number 3 Радиус распространения подъёма по строкам
width string 'min(88vw, 420px)' CSS-ширина иллюстрации
showControls boolean true Показывать подсказку с текущим состоянием
label string — Доступное имя SVG