Skip to content

← Examples

Pulley Diagram ​

A constraint-based physics diagram of three pulley wheels suspended from a ceiling bar by labeled ropes, lifting two hanging weights.

ts
import { Constraint, circle, createMark, layer, line, polygon, rect, text } from "gofish-graphics";

const r = 25;

const w2jut = 10;

const rope = {
  stroke: "#774e32",
  strokeWidth: 3,
  blendMode: "normal",
} as const;

const PulleyCircle = createMark(({ r = 25 }: { r?: number }) =>
  layer([
    circle({ r, stroke: "#828282", strokeWidth: 3, fill: "#C1C1C1" }).name(
      "wheel"
    ),
    circle({ r: 5, fill: "#555555" }).name("hub"),
  ]).relate(({ wheel, hub }) => [
    Constraint.align({ x: "middle", y: "middle" }, [wheel, hub]),
  ])
);

const Weight = createMark(
  ({
    width,
    height,
    label,
  }: {
    width: number;
    height: number;
    label: string;
  }) =>
    layer([
      polygon({
        // y-down free space (issue #143/#16): the inset top edge is at y=0, the
        // full-width bottom edge at y=height — a weight wider at the bottom.
        points: [
          [10, 0],
          [width - 10, 0],
          [width, height],
          [0, height],
        ],
        fill: "#545454",
        stroke: "#545454",
      }).name("body"),
      text({ text: label, fontSize: 10, fill: "white" }).name("label"),
    ]).relate(({ body, label }) => [
      Constraint.align({ x: "middle", y: "middle" }, [body, label]),
    ])
);

const container = document.getElementById("app");

// x/y shift the resolved bounding box to start at (20, 20) — the constraint
// layout produces negative coordinates and the root render does not auto-fit.
layer({ x: 20, y: 20 }, [
  // ── tier 1: shapes + letter labels + ropes ──────────────────────────
  layer([
    rect({
      h: 20,
      w: 9 * r,
      fill: "#C9C9C9",
      stroke: "#000",
      strokeWidth: 2,
    }).name("ceiling"),
    PulleyCircle({ r }).name("A"),
    PulleyCircle({ r }).name("B"),
    PulleyCircle({ r }).name("C"),
    Weight({ width: 30, height: 30, label: "W1" }).name("w1"),
    Weight({ width: 3 * r + w2jut, height: 30, label: "W2" }).name("w2"),
    text({ text: "A", fontSize: 12 }).name("Alabel"),
    text({ text: "B", fontSize: 12 }).name("Blabel"),
    text({ text: "C", fontSize: 12 }).name("Clabel"),
  ]).relate((c) => [
    // horizontal pulley cluster — each adjacent pair shares an edge:
    // B.start sits on A.middle (overlap by half a wheel), C.start on B.end.
    Constraint.align({ x: ["middle", "start"] }, [c.A, c.B]),
    Constraint.align({ x: ["end", "start"] }, [c.B, c.C]),

    // vertical placement: y-down free space matches Bluefish's ttb order
    // (ceiling on top, pulleys below, weights at the bottom) — #143/#16.
    Constraint.distribute({ dir: "y", spacing: 40, anchor: "edge" }, [c.ceiling, c.B]),
    Constraint.distribute({ dir: "y", spacing: 30, anchor: "edge" }, [c.B, c.A]),
    Constraint.distribute({ dir: "y", spacing: 50, anchor: "edge" }, [c.B, c.C]),

    // ceiling centered over the cluster (substitute for Bluefish <Group>)
    Constraint.align({ x: "middle" }, [c.B, c.ceiling]),

    // weights (negative spacing offsets each weight so its inset trapezoid
    // top sits under the rope source points — not natural anchor points,
    // so these stay as `distribute`)
    Constraint.distribute({ dir: "y", spacing: 50, anchor: "edge" }, [c.C, c.w2]),
    Constraint.distribute({ dir: "x", spacing: -20, anchor: "edge" }, [c.A, c.w2]),
    Constraint.distribute({ dir: "x", spacing: -15, anchor: "edge" }, [c.w1, c.A]),
    Constraint.align({ y: "middle" }, [c.w2, c.w1]),

    // pulley letter labels — 1px gap from the wheel; the label sits on
    // one side and y-anchors to one corner of the wheel.
    ...(
      [
        { pulley: c.A, label: c.Alabel, side: "left", y: "start" },
        { pulley: c.B, label: c.Blabel, side: "right", y: "start" },
        { pulley: c.C, label: c.Clabel, side: "right", y: "end" },
      ] as const
    ).flatMap(({ pulley, label, side, y }) => [
      Constraint.distribute(
        { dir: "x", spacing: 1, anchor: "edge" },
        side === "right" ? [pulley, label] : [label, pulley]
      ),
      Constraint.align({ y }, [pulley, label]),
    ]),

    // rope segments — drawing clauses, drawn between the placed shapes.
    // zOrder(-1): painted behind the shapes, so the wheels draw over rope
    // ends. `ropeSupport` is the unlabeled support rope from the ceiling
    // to B; the rest are named after the dimension letter (x/y/z/p/q/s)
    // they carry.
    line({ ...rope, target: "middle" }, [c.ceiling, c.B])
      .name("ropeSupport")
      .zOrder(-1),
    line({ ...rope, source: ["start", "middle"], target: "middle" }, [
      c.B,
      c.A,
    ])
      .name("ropeX")
      .zOrder(-1),
    line(
      { ...rope, source: ["end", "middle"], target: ["start", "middle"] },
      [c.B, c.C]
    )
      .name("ropeY")
      .zOrder(-1),
    line({ ...rope, target: ["end", "middle"] }, [c.ceiling, c.C])
      .name("ropeZ")
      .zOrder(-1),
    line({ ...rope, source: ["start", "middle"] }, [c.A, c.w1])
      .name("ropeP")
      .zOrder(-1),
    line({ ...rope, source: ["end", "middle"] }, [c.A, c.w2])
      .name("ropeQ")
      .zOrder(-1),
    line({ ...rope, source: "middle" }, [c.C, c.w2])
      .name("ropeS")
      .zOrder(-1),
  ]),

  // ── tier 2: dimension labels ────────────────────────────────────────
  text({ text: "x" }).name("labelX"),
  text({ text: "y" }).name("labelY"),
  text({ text: "z" }).name("labelZ"),
  text({ text: "p" }).name("labelP"),
  text({ text: "q" }).name("labelQ"),
  text({ text: "s" }).name("labelS"),
])
  .relate((c) => [
    // Each dimension label sits 5px right of its rope on x. On y, the
    // upper trio (x/y/z) shares ropeX's centerY; the lower trio (p/q/s)
    // shares ropeS's — à la Bluefish's `Align centerY [t1,t2,t3]` /
    // `[t6,t5,t4]`.
    ...[
      { rope: c.ropeX, label: c.labelX, yAnchorTo: c.ropeX },
      { rope: c.ropeY, label: c.labelY, yAnchorTo: c.labelX },
      { rope: c.ropeZ, label: c.labelZ, yAnchorTo: c.labelX },
      { rope: c.ropeS, label: c.labelS, yAnchorTo: c.ropeS },
      { rope: c.ropeQ, label: c.labelQ, yAnchorTo: c.labelS },
      { rope: c.ropeP, label: c.labelP, yAnchorTo: c.labelS },
    ].flatMap(({ rope, label, yAnchorTo }) => [
      Constraint.distribute({ dir: "x", spacing: 5, anchor: "edge" }, [rope, label]),
      Constraint.align({ y: "middle" }, [yAnchorTo, label]),
    ]),

    // ── granular paint order: relative z-order constraints ────────────
    // The ropes and the shapes all lie in tier 1, so these constraints
    // order them inside that tier. The ropes' default .zOrder(-1) keeps
    // the unmentioned ropes (Y/Z/P/Q) behind their circles; these
    // constraints carve out the four exceptions.
    Constraint.zAbove(c.ropeX, c.A), // x over A
    Constraint.zBelow(c.ropeX, c.B), // x under B
    Constraint.zAbove(c.ropeSupport, c.B), // ceiling→B over B
    Constraint.zAbove(c.ropeS, c.C), // s over C
  ])
  .render(container, {});