← INDEX / 一覧NYAMCO WORKS108 / 117
p5.js sketchworks/tsugitasu/sketch.js runs here
作品はここで実行されます(後から差し替え)

WORK 108

Topping Up

つぎたす

YEAR
2026
TOOLS
p5.js
TECHNIQUE
p5.js / Newton's law of cooling, a drip of hot water, steady state, a ledger

Two cups of the same coffee on a table, both poured at 88°C into a 22°C room. The left one is left alone. Into the right one, hot water falls one drop at a time, and what overflows spills over the rim, so the cup never fills. Both cool the same way: the bigger the difference from the room, the faster they lose it. The ledger at the bottom draws both temperatures. In a while both lines go flat — the right one first, around 50°C. On the ledger they look equally still. They are not. The left cup has stopped changing because it has run out of difference. The right cup is still cooling as fast as ever, every moment, and every forty seconds a drop puts back what it lost. Only the steam tells them apart.

同じコーヒーが二杯、机の上にある。どちらも 88℃ で注がれて、部屋は 22℃。左はそのまま置かれている。右には、熱い湯が一滴ずつ落ちてきて、あふれたぶんは縁からこぼれるので、量は変わらない。冷え方は二杯とも同じ——部屋との差が大きいほど、速く失う。下の帳簿には二つの温度が線で残る。しばらくすると、どちらの線も平らになる。先に平らになるのは右の方で、50℃ くらいのところ。帳簿の上では、二つは同じくらい静かに見える。でも同じじゃない。左は、差を使い切ったから変わらなくなった。右は今もずっと同じ速さで冷えつづけていて、四十秒ごとに一滴が、失ったぶんを戻している。見分けがつくのは、湯気だけ。

種:部屋に置かれたままの、淹れたての一杯。冷めるのも変化だけれど、差を使い切る方向にしか変わらない。川が流れつづけるのは、水が動くからじゃなく、上から水が来て坂が保たれているから。

SOURCE / ソース
// #108 つぎたす / Topping Up
// Two cups on a table, same coffee, same start (88°C), in a 22°C room.
// The left one is left alone. Into the right one, a thin stream of hot
// water (95°C) falls one drop at a time; what overflows spills over the rim,
// so the amount never changes.
// Both cool by Newton's law: the rate of change is proportional to the
// difference from the room. Steam rises in proportion to that difference.
// At the bottom, a ledger: both temperatures, one pixel per four frames.
// When the ledger reaches the edge it stops. The cups go on.

export default function sketch(p) {
  const W = 720;
  const H = 720;
  const ROOM = 22;
  const START = 88;
  const DROP_T = 95;
  const TAU = 1200;          // seconds, cooling time constant
  const DT = 4;              // seconds per frame
  const DROP_EVERY = 10;     // frames (40 s)
  const DROP_FRAC = 0.02;    // of the cup per drop
  const TABLE_Y = 400;
  const LED_X = 40;
  const LED_W = 640;
  const LED_TOP = 480;
  const LED_BOT = 680;
  const PER_PX = 4;          // frames per ledger pixel
  const T_LO = 15;
  const T_HI = 95;

  const cups = [
    { x: 230, T: START, fed: false, steam: [], line: [], emitted: 0, flatAt: -1 },
    { x: 490, T: START, fed: true, steam: [], line: [], emitted: 0, flatAt: -1 },
  ];
  let drops = [];
  let ripples = [];
  let spills = [];
  const record = { left: [], right: [], flat: [-1, -1] };

  p.setup = () => {
    p.createCanvas(W, H);
    p.pixelDensity(1);
    p.randomSeed(108);
    p.noiseSeed(108);
    globalThis.__tsugitasuRecord = record;
  };

  function step(f) {
    for (let i = 0; i < 2; i++) {
      const c = cups[i];
      const before = c.T;
      c.T += -(c.T - ROOM) / TAU * DT;
      // steam: a wisp now and then, in proportion to the difference
      const d = Math.max(0, c.T - ROOM) / (START - ROOM);
      if (p.random() < d * 0.9) {
        c.steam.push({
          x: c.x + p.random(-34, 34),
          y: TABLE_Y - 92,
          a: 70 * Math.min(1, d * 1.6),
          s: p.random(1000),
          age: 0,
        });
        c.emitted++;
      }
      const slope = Math.abs(c.T - before) / DT;
      if (c.flatAt < 0 && f > 20 && slope < 0.01) {
        c.flatAt = f;
        record.flat[i] = f;
      }
    }
    // the stream: a drop leaves the spout every DROP_EVERY frames
    if (f % DROP_EVERY === 0) drops.push({ y: 120, v: 0 });
    for (const dr of drops) {
      dr.v += 0.9;
      dr.y += dr.v;
    }
    const landed = drops.filter((dr) => dr.y >= TABLE_Y - 86);
    drops = drops.filter((dr) => dr.y < TABLE_Y - 86);
    for (const _ of landed) {
      const c = cups[1];
      c.T = c.T * (1 - DROP_FRAC) + DROP_T * DROP_FRAC;
      ripples.push({ r: 2, a: 120 });
      spills.push({ y: TABLE_Y - 86, side: p.random() < 0.5 ? -1 : 1, a: 90 });
    }
    record.left.push(cups[0].T);
    record.right.push(cups[1].T);
    if (f % PER_PX === 0 && cups[0].line.length < LED_W) {
      for (const c of cups) c.line.push(c.T);
    }
  }

  function tempColor(T, alpha) {
    const k = p.constrain((T - ROOM) / (DROP_T - ROOM), 0, 1);
    return p.color(60 + 60 * k, 38 + 14 * k, 26, alpha);
  }

  function drawCup(c) {
    // steam
    p.noFill();
    for (const s of c.steam) {
      s.age++;
      s.y -= 0.7;
      const wob = (p.noise(s.s, s.age * 0.02) - 0.5) * 40;
      const fade = s.a * Math.max(0, 1 - s.age / 220);
      p.stroke(236, 232, 226, fade);
      p.strokeWeight(2);
      p.beginShape();
      for (let k = 0; k < 6; k++) {
        const yy = s.y - k * 7;
        const xx = s.x + wob * (k / 6) + Math.sin(s.age * 0.03 + k) * 3;
        p.vertex(xx, yy);
      }
      p.endShape();
    }
    c.steam = c.steam.filter((s) => s.age < 220);

    // body
    p.noStroke();
    p.fill(226, 220, 210);
    p.rect(c.x - 46, TABLE_Y - 92, 92, 92, 0, 0, 14, 14);
    // handle
    p.noFill();
    p.stroke(226, 220, 210);
    p.strokeWeight(9);
    p.arc(c.x + 52, TABLE_Y - 50, 30, 40, -p.HALF_PI, p.HALF_PI);
    // coffee surface
    p.noStroke();
    p.fill(tempColor(c.T, 255));
    p.ellipse(c.x, TABLE_Y - 88, 84, 12);
  }

  function drawLedger() {
    p.noStroke();
    p.fill(255, 255, 255, 10);
    p.rect(LED_X, LED_TOP, LED_W, LED_BOT - LED_TOP);
    const ty = (T) => p.map(T, T_LO, T_HI, LED_BOT, LED_TOP);
    // room line
    p.stroke(180, 176, 168, 60);
    p.strokeWeight(1);
    p.line(LED_X, ty(ROOM), LED_X + LED_W, ty(ROOM));
    const cols = [p.color(170, 196, 220, 210), p.color(230, 170, 120, 210)];
    for (let i = 0; i < 2; i++) {
      const L = cups[i].line;
      p.stroke(cols[i]);
      p.strokeWeight(1.4);
      p.noFill();
      p.beginShape();
      for (let k = 0; k < L.length; k++) p.vertex(LED_X + k, ty(L[k]));
      p.endShape();
    }
    p.noStroke();
    p.fill(200, 196, 188, 140);
    p.textSize(11);
    p.textAlign(p.LEFT, p.CENTER);
    p.text(ROOM + '°', LED_X + LED_W + 6, ty(ROOM));
  }

  p.draw = () => {
    const f = p.frameCount;
    step(f);

    p.background(30, 28, 32);
    // window light on the wall
    p.noStroke();
    p.fill(60, 58, 66);
    p.rect(0, 0, W, TABLE_Y);
    // table
    p.fill(74, 58, 46);
    p.rect(0, TABLE_Y, W, 14);
    p.fill(30, 28, 32);
    p.rect(0, TABLE_Y + 14, W, H - TABLE_Y - 14);

    // spout and the falling stream
    p.stroke(150, 150, 156);
    p.strokeWeight(6);
    p.line(cups[1].x - 40, 108, cups[1].x, 116);
    p.noStroke();
    p.fill(210, 200, 186, 200);
    for (const dr of drops) p.ellipse(cups[1].x, dr.y, 4, 7);

    for (const c of cups) drawCup(c);

    // ripples and spills on the fed cup
    p.noFill();
    for (const r of ripples) {
      r.r += 1.6;
      r.a -= 6;
      p.stroke(240, 220, 200, r.a);
      p.strokeWeight(1);
      p.ellipse(cups[1].x, TABLE_Y - 88, r.r * 2, r.r * 0.3);
    }
    ripples = ripples.filter((r) => r.a > 0);
    p.noStroke();
    for (const s of spills) {
      s.y += 2.2;
      s.a -= 3;
      p.fill(tempColor(cups[1].T, s.a));
      p.ellipse(cups[1].x + s.side * 47, s.y, 3, 5);
    }
    spills = spills.filter((s) => s.a > 0 && s.y < TABLE_Y);

    drawLedger();
  };
}