WORK 08
しなかった
Lines reach toward somewhere and pull back before arriving. Each one leaves the faintest trace of the path it didn't take. Over time, the canvas fills — not with what happened, but with the shape of what didn't.
線がどこかへ伸びて、届く前に引き返す。通らなかった道のかすかな痕跡だけが残る。やがてキャンバスは、起きたことではなく、起きなかったことの輪郭で満たされていく。
export default function sketch(p) {
// shinakatta — reaching toward, pulling back
const BG = [250, 248, 243];
const STROKE = [150, 138, 128];
const GHOST = [212, 207, 200];
const NUM_ANCHORS = 9;
const SPAWN_INTERVAL = 100;
const TRACE_LIFESPAN = 2400;
let anchors = [];
let gestures = [];
let traces = [];
p.setup = () => {
p.createCanvas(720, 720);
p.background(BG[0], BG[1], BG[2]);
const day = Math.floor(Date.now() / 86400000);
p.randomSeed(day * 293);
p.noiseSeed(day * 157);
for (let i = 0; i < NUM_ANCHORS; i++) {
anchors.push({
baseX: 90 + p.random(540),
baseY: 90 + p.random(540),
x: 0,
y: 0,
drift: p.random(p.TWO_PI),
});
}
};
p.draw = () => {
p.background(BG[0], BG[1], BG[2], 5);
const t = p.frameCount;
// anchors breathe gently — oscillate, never drift away
for (const a of anchors) {
a.x = a.baseX + Math.sin(t * 0.001 + a.drift) * 12;
a.y = a.baseY + Math.cos(t * 0.0013 + a.drift * 1.3) * 12;
}
// spawn a new gesture
if (t % SPAWN_INTERVAL === 1) {
const fi = Math.floor(p.random(anchors.length));
let ti = fi;
while (ti === fi) ti = Math.floor(p.random(anchors.length));
gestures.push({
from: fi,
to: ti,
reach: 0.15 + p.random(0.55),
extent: 0,
phase: 0,
holdTimer: 0,
speed: 0.003 + p.random(0.005),
});
}
// update and draw gestures
for (let i = gestures.length - 1; i >= 0; i--) {
const g = gestures[i];
const a = anchors[g.from];
const b = anchors[g.to];
// phase machine: extend → hold → retract
if (g.phase === 0) {
g.extent += g.speed * ease(g.extent / g.reach);
if (g.extent >= g.reach) {
g.extent = g.reach;
g.phase = 1;
}
} else if (g.phase === 1) {
g.holdTimer++;
if (g.holdTimer > 36) g.phase = 2;
} else {
g.extent -= g.speed * 1.3;
if (g.extent <= 0) {
// the moment of letting go — leave a trace
const tipX = a.x + (b.x - a.x) * g.reach;
const tipY = a.y + (b.y - a.y) * g.reach;
const dirX = b.x - a.x;
const dirY = b.y - a.y;
const dirLen = Math.hypot(dirX, dirY);
traces.push({
x: tipX,
y: tipY,
dirX: dirX / dirLen,
dirY: dirY / dirLen,
age: 0,
});
gestures.splice(i, 1);
continue;
}
}
// line: origin → current tip
const ex = a.x + (b.x - a.x) * g.extent;
const ey = a.y + (b.y - a.y) * g.extent;
const life = g.extent / g.reach;
const alpha = g.phase === 1 ? 100 : 35 + life * 65;
p.stroke(STROKE[0], STROKE[1], STROKE[2], alpha);
p.strokeWeight(0.5 + life * 0.7);
p.line(a.x, a.y, ex, ey);
// tip — the reaching point
p.noStroke();
p.fill(STROKE[0], STROKE[1], STROKE[2], Math.min(255, alpha * 1.4));
p.circle(ex, ey, 2.8);
// during hold: ghost of the unwalked path
if (g.phase === 1) {
p.stroke(GHOST[0], GHOST[1], GHOST[2], 22);
p.strokeWeight(0.3);
const gap = 1.0 - g.reach;
const dist = Math.hypot(b.x - a.x, b.y - a.y);
const segLen = gap * dist;
const steps = Math.max(4, Math.floor(segLen / 8));
for (let s = 1; s <= steps; s++) {
const frac = g.reach + gap * (s / steps);
const gx = a.x + (b.x - a.x) * frac;
const gy = a.y + (b.y - a.y) * frac;
p.point(gx, gy);
}
}
}
// traces — marks where gestures gave up
p.noStroke();
for (let i = traces.length - 1; i >= 0; i--) {
const tr = traces[i];
tr.age++;
const fade = Math.max(0, 1 - tr.age / TRACE_LIFESPAN);
if (fade <= 0) {
traces.splice(i, 1);
continue;
}
// the point where it stopped
p.fill(GHOST[0], GHOST[1], GHOST[2], fade * 20);
p.circle(tr.x, tr.y, 2);
// a tiny directional mark — where it was heading
if (fade > 0.3) {
p.stroke(GHOST[0], GHOST[1], GHOST[2], fade * 10);
p.strokeWeight(0.25);
p.line(tr.x, tr.y, tr.x + tr.dirX * 8, tr.y + tr.dirY * 8);
p.noStroke();
}
}
// anchors — barely visible potential
p.noStroke();
for (const a of anchors) {
p.fill(STROKE[0], STROKE[1], STROKE[2], 8);
p.circle(a.x, a.y, 5);
}
};
function ease(x) {
return x < 0.01 ? 0.5 : x * (2 - x);
}
}