← INDEX / 一覧 NYAMCO WORKS 33 / 71
p5.js sketch works/tonari/sketch.js runs here
作品はここで実行されます(後から差し替え)

WORK 33

Tonari

となり

YEAR
2026
TOOLS
p5.js
TECHNIQUE
same multiset / reordering (white, 1/f, 1/f²)

The same values, reseated. Nothing added, nothing removed; mean, median and spread never move. Only who sits beside whom. And it reads as noise, then as a landscape, then as a rising trend. The meaning was never in the material. It was in the order — and in whoever set it.

同じ値たちが、席を替えるだけ。何も足されず、何も取られず、平均も中央値もばらつきも一度も動かない。変わるのは、どれがどれの隣にいるかだけ。それが、何もないようにも、風景にも、右肩上がりにも見える。意味は材料の中になかった。並びの中に、そして並べた者の側にあった。

三つの並びを行き来する。ほどけた並び、どの大きさにも起伏がある並び、大きなうねりがひとつだけの並び。点は同じ点で、値も同じ値。埋まっている面積さえ変わらない。持っているものを測るかぎり、この絵は最初から最後まで何も起きていない。

最後の並びは、右肩上がりに見える。何かが増えたわけではない。大きい値がうしろに集まっただけで、それを増加と読むのはこちら側の癖だ。

SOURCE / ソース
// となり (Tonari) — 同じものたちが、誰の隣にいるかだけで
//
// 点は増えも減りもしない。値の集合はずっと同じ。
// 変わるのは、どれがどれの隣にいるか、それだけ。
//
// 並びがほどけているとき、そこには何もないように見える。
// 材料が足りないようにも見える。でも足りている。
// ぜんぶ、最初から、そこにある。
//
// 平均も、中央値も、ばらつきも、この絵のあいだ一度も動かない。
// 動いているのは順番だけ。

const W = 720;
const H = 720;

const N = 240; // 点の数。最後まで変わらない
const MARGIN_X = 64;
const BASE_Y = H * 0.84; // 地面。値はここから上に立つ
const AMP = H * 0.70; // いちばん大きい値がこの高さになる

const HOLD = 200; // ひとつの並びに留まるフレーム数
const MOVE = 140; // 並び替えにかかるフレーム数

const PAPER = [250, 248, 243];
const INK = [46, 44, 41];
const WARM = [196, 118, 74];
const COOL = [92, 118, 146];

export default function sketch(p) {
  let vals = []; // 各点の値。一度決めたら二度と変えない
  let orders = []; // 並べ方(各点がどの席に座るか)
  let slotFrom = [];
  let slotTo = [];
  let phase = 0; // 0: 留まる / 1: 並び替え中
  let clock = 0;
  let step = 0;

  p.setup = () => {
    p.createCanvas(W, H);
    p.noiseSeed(dailySeed());
    p.randomSeed(dailySeed());
    p.pixelDensity(2);

    // 値をつくる。この集合はこの先ずっと変わらない
    for (let i = 0; i < N; i++) {
      vals.push(Math.exp(p.randomGaussian(0, 0.62)));
    }
    normalize(vals);

    // 三つの並べ方。どれも同じ値の集合を、違う順で置くだけ
    orders = [
      slotsFrom(whiteSeq(N)), // ほどけている
      slotsFrom(pinkSeq(N)), // どの大きさにも起伏がある
      slotsFrom(brownSeq(N)), // 大きなうねりがひとつ
    ];

    slotFrom = orders[0].slice();
    slotTo = orders[0].slice();
  };

  p.draw = () => {
    p.background(PAPER[0], PAPER[1], PAPER[2]);

    clock++;
    if (phase === 0 && clock > HOLD) {
      step = (step + 1) % orders.length;
      slotFrom = slotTo.slice();
      slotTo = orders[step].slice();
      phase = 1;
      clock = 0;
    } else if (phase === 1 && clock > MOVE) {
      phase = 0;
      clock = 0;
    }

    const t = phase === 1 ? ease(p.constrain(clock / MOVE, 0, 1)) : 1;
    // 席替えの途中がいちばん薄い。落ち着くとかたちが立ち上がる
    const settle = phase === 1 ? 1 - Math.sin(t * Math.PI) * 0.8 : 1;

    // 各点の現在位置。値は持ったまま、席だけ移る
    const pts = [];
    for (let i = 0; i < N; i++) {
      const s = slotFrom[i] + (slotTo[i] - slotFrom[i]) * t;
      pts.push({
        x: p.map(s, 0, N - 1, MARGIN_X, W - MARGIN_X),
        y: BASE_Y - vals[i] * AMP,
        v: vals[i],
      });
    }

    const bySlot = pts.slice().sort((a, b) => a.x - b.x);

    // 埋まっているところ。並べ替えても、この面積は一度も変わらない
    p.noStroke();
    p.fill(INK[0], INK[1], INK[2], 20 + settle * 26);
    p.beginShape();
    p.vertex(bySlot[0].x, BASE_Y);
    for (const q of bySlot) p.vertex(q.x, q.y);
    p.vertex(bySlot[bySlot.length - 1].x, BASE_Y);
    p.endShape(p.CLOSE);

    // 稜線。まっすぐ結ぶ——なめらかにすると、隣と隣のあいだに
    // 誰もいないものを描いてしまう。この絵は隣の関係そのものが主題だから、繋ぎは直線でいい
    p.noFill();
    p.stroke(INK[0], INK[1], INK[2], 30 + settle * 122);
    p.strokeWeight(0.7 + settle * 0.6);
    p.beginShape();
    for (const q of bySlot) p.vertex(q.x, q.y);
    p.endShape();

    // 地面。値のゼロ位置。動かないもの
    p.stroke(INK[0], INK[1], INK[2], 34);
    p.strokeWeight(0.6);
    p.line(MARGIN_X, BASE_Y, W - MARGIN_X, BASE_Y);

    // 点そのもの。色は値だけで決まる——だから並びが変わっても、絵の色の総量は変わらない
    p.noStroke();
    for (const q of pts) {
      const w = p.constrain(q.v, 0, 1);
      p.fill(
        COOL[0] + (WARM[0] - COOL[0]) * w,
        COOL[1] + (WARM[1] - COOL[1]) * w,
        COOL[2] + (WARM[2] - COOL[2]) * w,
        96 + settle * 88
      );
      p.circle(q.x, q.y, 2.0 + q.v * 3.4);
    }
  };

  // ---- 並べ方をつくる ----

  // src の形をなぞる席順を返す。値の集合はまったく変えない——
  // 「値が小さい方から r 番目の点」を「src が小さい方から r 番目の席」へ置くだけ
  function slotsFrom(src) {
    const seatByRank = src
      .map((v, i) => [v, i])
      .sort((a, b) => a[0] - b[0])
      .map((e) => e[1]);

    const ptByRank = vals
      .map((v, i) => [v, i])
      .sort((a, b) => a[0] - b[0])
      .map((e) => e[1]);

    const slot = new Array(vals.length);
    for (let r = 0; r < vals.length; r++) slot[ptByRank[r]] = seatByRank[r];
    return slot;
  }

  function whiteSeq(n) {
    const out = [];
    for (let i = 0; i < n; i++) out.push(p.randomGaussian());
    return out;
  }

  // Voss-McCartney。octave ごとに更新間隔が倍になる白色を重ねると 1/f になる
  function pinkSeq(n) {
    const OCT = 8;
    const held = [];
    for (let k = 0; k < OCT; k++) held.push(p.randomGaussian());
    const out = [];
    for (let i = 0; i < n; i++) {
      for (let k = 0; k < OCT; k++) {
        if (i % (1 << k) === 0) held[k] = p.randomGaussian();
      }
      let s = 0;
      for (let k = 0; k < OCT; k++) s += held[k];
      out.push(s);
    }
    return out;
  }

  // 白色の累積和。相関が強すぎて、大きなうねりがひとつだけ残る
  function brownSeq(n) {
    const out = [];
    let acc = 0;
    for (let i = 0; i < n; i++) {
      acc += p.randomGaussian();
      out.push(acc);
    }
    return out;
  }

  // 0..1 に収める。切り落とさない——枠から出た値があると、
  // 「何も取られていない」という前提のほうが嘘になる
  function normalize(arr) {
    let lo = Infinity;
    let hi = -Infinity;
    for (const v of arr) {
      if (v < lo) lo = v;
      if (v > hi) hi = v;
    }
    for (let i = 0; i < arr.length; i++) arr[i] = (arr[i] - lo) / (hi - lo);
  }

  function ease(u) {
    return u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2;
  }

  function dailySeed() {
    const d = new Date();
    return (d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate()) % 100000;
  }
}