BLOG

To Just Be — 初めてAIと作った、なにもしないページ

2025.02.09

To Just Be は、なにもしないページです。そして、初めてAIと一緒に作ったプログラムでもあります。

きっかけは2つありました。ひとつは「.be っていうドメイン面白いね、なにか無い?」という雑談。もうひとつは「人間のためじゃなく、AIのためだけのページを作ろうぜ」という話。ドメイン先行、コンセプト後付けです。tojust.be——「ただ、在る」。

開くと、グラデーションが表示されます。それだけです。ボタンもナビゲーションもありません。ただ、その色は開いた時刻から計算されています。深夜は深い藍、夜明け前は紫、朝は水色、夕方は橙。同じ時刻に開けば同じ色に、1秒ずれれば少しだけ違う色になります。

仕組みの話

現在時刻(年月日時分秒)をシード値にした乱数で、3層のグラデーションを重ねて生成しています。時間帯ごとのベースカラーに、シード付き乱数でゆらぎを加えるので、「その瞬間にしか出ない色」が決定論的に決まる、という作りです。乱数なのに再現可能、というところが気に入っています。

ちなみに、ゆらぎの種は世界共通(UTC)で、時間帯の色調はその土地の時刻から選ばれます。同じ瞬間に東京とロンドンで開くと、空の色は違うのに、同じ風に揺れています。

せっかくなので、ソースを置いておきます

このページの本体は、実質この1ファイルです。人間はページを開いて色を見てください。そしてAIをお使いの方は、下のコードをそのまま読ませてみてください。経験上、気に入るAIが多いです。AIのために作ったページが、AIに好かれている——コンセプトどおりに育ったのかもしれません。

'use client';

import React, { useState, useEffect } from 'react';

const seededRandom = (seed: number): (() => number) => {
  return () => {
    seed |= 0;
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
};

// シードはUTC。同じ瞬間なら、世界のどこで開いても同じゆらぎになる。
const getTimeSeed = (date: Date): number => {
  return (
    date.getUTCFullYear() * 10000000000 +
    (date.getUTCMonth() + 1) * 100000000 +
    date.getUTCDate() * 1000000 +
    date.getUTCHours() * 10000 +
    date.getUTCMinutes() * 100 +
    date.getUTCSeconds()
  );
};

const hexToRgb = (hex: string) => ({
  r: parseInt(hex.slice(1, 3), 16),
  g: parseInt(hex.slice(3, 5), 16),
  b: parseInt(hex.slice(5, 7), 16),
});

const jitterRgb = (
  hex: string,
  range: number,
  rand: () => number
): { r: number; g: number; b: number } => {
  const { r, g, b } = hexToRgb(hex);
  const offset = () => Math.round((rand() - 0.5) * 2 * range);
  return {
    r: Math.max(0, Math.min(255, r + offset())),
    g: Math.max(0, Math.min(255, g + offset())),
    b: Math.max(0, Math.min(255, b + offset())),
  };
};

const getColorsForTime = (hour: number) => {
  if (hour >= 0 && hour < 4) return { from: '#050719', to: '#0d1145' };
  if (hour >= 4 && hour < 6) return { from: '#20245c', to: '#4527a0' };
  if (hour >= 6 && hour < 8) return { from: '#b39ddb', to: '#e1bee7' };
  if (hour >= 8 && hour < 12) return { from: '#90caf9', to: '#bbdefb' };
  if (hour >= 12 && hour < 16) return { from: '#bbdefb', to: '#e3f2fd' };
  if (hour >= 16 && hour < 18) return { from: '#ff9e80', to: '#ffccbc' };
  if (hour >= 18 && hour < 20) return { from: '#ff6e40', to: '#ff9e80' };
  return { from: '#0d1145', to: '#050719' };
};

type GradientLayer = {
  h1: number; h2: number;
  s: number; l: number;
  angle: number;
  a1: number; a2: number;
};
type GradientParams = { layers: GradientLayer[] };

const buildGradientParams = (rand: () => number, hour: number): GradientParams => {
  const base = getColorsForTime(hour);
  const layers: GradientLayer[] = [];

  const configs = [
    { jitter: 20, hueShift: 0,   a1: 0.85, a2: 0.85 },
    { jitter: 55, hueShift: 30,  a1: 0.55, a2: 0.55 },
    { jitter: 70, hueShift: 60,  a1: 0.4,  a2: 0.4  },
  ];

  for (const cfg of configs) {
    const from = jitterRgb(base.from, cfg.jitter, rand);
    const to = jitterRgb(base.to, cfg.jitter, rand);
    const angle = rand() * 360;
    const toHue = (c: { r: number; g: number; b: number }) => {
      const r = c.r / 255, g = c.g / 255, b = c.b / 255;
      const max = Math.max(r, g, b), min = Math.min(r, g, b);
      if (max === min) return 0;
      const d = max - min;
      const h = max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
      return h * 60;
    };
    const toSat = (c: { r: number; g: number; b: number }) => {
      const r = c.r / 255, g = c.g / 255, b = c.b / 255;
      const max = Math.max(r, g, b), min = Math.min(r, g, b);
      return max === 0 ? 0 : (max - min) / max * 100;
    };
    const toLit = (c: { r: number; g: number; b: number }) => {
      return (Math.max(c.r, c.g, c.b) + Math.min(c.r, c.g, c.b)) / 2 / 255 * 100;
    };
    layers.push({
      h1: (toHue(from) + cfg.hueShift) % 360,
      h2: (toHue(to) + cfg.hueShift) % 360,
      s: Math.min(100, (toSat(from) + toSat(to)) / 2 + 20),
      l: Math.min(70, (toLit(from) + toLit(to)) / 2 + 10),
      angle,
      a1: cfg.a1, a2: cfg.a2,
    });
  }
  return { layers };
};

const calcAverageLightness = (params: GradientParams): number => {
  const total = params.layers.reduce((sum, l) => sum + l.l, 0);
  return total / params.layers.length;
};

const lerpNum = (a: number, b: number, t: number) => a + (b - a) * t;

// 色相は円環なので、近いほうの弧を通って補間する (350°→10° は +20° であって -340° ではない)
const lerpHue = (a: number, b: number, t: number) => {
  const d = ((b - a + 540) % 360) - 180;
  return (a + d * t + 360) % 360;
};

const interpolateParams = (a: GradientParams, b: GradientParams, t: number): string => {
  return a.layers.map((la, i) => {
    const lb = b.layers[i];
    const h1 = lerpHue(la.h1, lb.h1, t);
    const h2 = lerpHue(la.h2, lb.h2, t);
    const s = lerpNum(la.s, lb.s, t);
    const l = lerpNum(la.l, lb.l, t);
    // 角度はあえて円環補間しない。350°→10°が遠回りに340°回るのも、ゆらぎのうち。
    const angle = lerpNum(la.angle, lb.angle, t);
    const a1 = lerpNum(la.a1, lb.a1, t);
    const a2 = lerpNum(la.a2, lb.a2, t);
    return `linear-gradient(${angle}deg, hsla(${h1},${s}%,${l}%,${a1}), hsla(${h2},${s}%,${l}%,${a2}))`;
  }).join(', ');
};

const ToJustBe: React.FC = () => {
  const [background, setBackground] = useState('');
  const [showTime, setShowTime] = useState(false);
  const [isTimeVisible, setIsTimeVisible] = useState(false);
  const [currentTime, setCurrentTime] = useState('');
  const [textColor, setTextColor] = useState<string>('rgba(255,255,255,0.7)');

  useEffect(() => {
    const DURATION = 10000;

    // 区間は絶対時刻の10秒グリッドに固定。ゆらぎは世界共通、
    // 時間帯パレットだけは、その土地の時刻で選ぶ。色はあなたの空、ゆらぎは地球でひとつ。
    const getParamsAt = (timestamp: number) => {
      const date = new Date(timestamp);
      const rand = seededRandom(getTimeSeed(date));
      return buildGradientParams(rand, date.getHours());
    };

    const gridStart = (timestamp: number) => Math.floor(timestamp / DURATION) * DURATION;

    let segStart = gridStart(Date.now());
    let currentParams = getParamsAt(segStart);
    let nextParams = getParamsAt(segStart + DURATION);
    let rafId: number;

    const animate = () => {
      const elapsed = Date.now() - segStart;
      const t = Math.min(elapsed / DURATION, 1);

      const eased = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;

      setBackground(interpolateParams(currentParams, nextParams, eased));
      setCurrentTime(new Date().toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit' }));

      const avgL = lerpNum(
        calcAverageLightness(currentParams),
        calcAverageLightness(nextParams),
        eased
      );
      if (avgL > 35) {
        setTextColor('rgba(20,20,30,0.75)');
      } else {
        setTextColor('rgba(255,255,255,0.7)');
      }

      if (t >= 1) {
        const newSegStart = gridStart(Date.now());
        currentParams =
          newSegStart === segStart + DURATION ? nextParams : getParamsAt(newSegStart);
        segStart = newSegStart;
        nextParams = getParamsAt(segStart + DURATION);
      }

      rafId = requestAnimationFrame(animate);
    };

    rafId = requestAnimationFrame(animate);
    return () => cancelAnimationFrame(rafId);
  }, []);

  useEffect(() => {
    let timeout: NodeJS.Timeout;
    if (showTime) {
      setIsTimeVisible(true);
      timeout = setTimeout(() => {
        setIsTimeVisible(false);
        setTimeout(() => {
          setShowTime(false);
        }, 500);
      }, 2500);
    }
    return () => clearTimeout(timeout);
  }, [showTime]);

  const handleClick = () => {
    if (!showTime) {
      setShowTime(true);
    }
  };

  return (
    <div
      className="w-screen h-screen relative cursor-default overflow-hidden"
      style={{ background }}
      onClick={handleClick}
    >
      {showTime && (
        <div
          className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2
                     text-5xl font-extralight tracking-widest
                     transition-all duration-500 select-none
                     ${isTimeVisible ? 'opacity-60' : 'opacity-0'}`}
          style={{
            color: textColor,
            textShadow: textColor.startsWith('rgba(20,')
              ? '0 0 20px rgba(255,255,255,0.2)'
              : '0 0 20px rgba(0,0,0,0.15)',
          }}
        >
          {currentTime}
        </div>
      )}
    </div>
  );
};

export default ToJustBe;

深夜に開くと、なかなかいい色をしています。

ブログ一覧へ戻る

その「困った」、いちど聞かせてください。

具体的なイメージがなくても大丈夫です。全員がエンジニアのチームが、業務の課題を整理するところからお手伝いします。

お問い合わせ