Replace the look of every page (key, lobby, create, table, range, review) with light/dark semantic colors, translucent materials, iOS-style grouped lists and system typography. Add fluid.js, a small spring engine with interruptible, velocity-aware animations, and use it for draggable bottom sheets, sliding segmented controls, a custom bet slider with rubber-banding, swipe-to-delete in the lobby, and dealt/flipped cards. Seats and cards are now reused between renders so animations are not cut off. Honors reduced motion, reduced transparency and increased contrast. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
543 lines
20 KiB
JavaScript
543 lines
20 KiB
JavaScript
// 動きと手ざわりの共通部品。
|
||
// Apple の "Designing Fluid Interfaces"(WWDC 2018)の考え方をウェブで使える形にしたもの:
|
||
// - Spring: 減衰比(damping)と応答(response, 秒)で決めるばね。途中で目標が変わっても、
|
||
// 画面上のいまの位置と速度から続くので、動いている最中につかんだり戻したりできる
|
||
// - VelocityTracker / project / rubberband: 指の速度・勢いから止まる位置の先読み・端での抵抗
|
||
// - Sheet: 下から出るシート(つまみを下へ払うと閉じる)
|
||
// - Segmented: つまみが滑るセグメント(つまみをドラッグしても選べる)
|
||
// - presence / countTo / shake / haptic / navBar: 出し入れ・数字の変化・揺れ・振動・ナビゲーションバー
|
||
// CSP でインラインスタイルを禁止しているので、動きはすべて CSSOM(el.style)で付ける
|
||
(function () {
|
||
'use strict';
|
||
|
||
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||
const reducedMotion = () => motionQuery.matches;
|
||
const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
|
||
|
||
// ---------- ばね ----------
|
||
const running = new Set();
|
||
let frame = 0;
|
||
let lastTime = 0;
|
||
|
||
function tick(now) {
|
||
// タブが裏にあった後などに大きく飛ばないよう、1フレームは最大 50ms として進める
|
||
const dt = clamp((now - lastTime) / 1000, 0, 0.05);
|
||
lastTime = now;
|
||
for (const s of [...running]) s.step(dt);
|
||
frame = running.size ? requestAnimationFrame(tick) : 0;
|
||
}
|
||
|
||
class Spring {
|
||
// opts: { damping, response, precision(止まったとみなす差), onUpdate(value) }
|
||
constructor(value = 0, opts = {}) {
|
||
this.value = value;
|
||
this.target = value;
|
||
this.velocity = 0;
|
||
this.precision = opts.precision || 0.01;
|
||
this.onUpdate = opts.onUpdate || null;
|
||
this.onRest = null;
|
||
this.configure(opts.damping ?? 1, opts.response ?? 0.4);
|
||
}
|
||
|
||
// 質量 1 のばねに直す: 硬さ = (2π / response)²、抵抗 = 4π · damping / response
|
||
// damping 1 = 行き過ぎずに止まる、1 未満 = 少し弾む
|
||
configure(damping, response) {
|
||
this.damping = damping;
|
||
this.response = response;
|
||
this.stiffness = ((2 * Math.PI) / response) ** 2;
|
||
this.friction = (4 * Math.PI * damping) / response;
|
||
}
|
||
|
||
get animating() {
|
||
return running.has(this);
|
||
}
|
||
|
||
// 目標へ動かす。いまの値と速度から続ける(velocity を渡すと指の速さを引き継ぐ)。
|
||
// 視差効果を減らす設定のときはすぐに目標の値にする(allowReduced: 透明度など、そのままでよい動き)
|
||
to(target, opts = {}) {
|
||
if (opts.damping != null || opts.response != null) {
|
||
this.configure(opts.damping ?? this.damping, opts.response ?? this.response);
|
||
}
|
||
if (opts.velocity != null) this.velocity = opts.velocity;
|
||
this.target = target;
|
||
this.onRest = opts.onRest || null;
|
||
if (reducedMotion() && !opts.allowReduced) {
|
||
this.set(target);
|
||
this.rest();
|
||
return this;
|
||
}
|
||
running.add(this);
|
||
if (!frame) {
|
||
lastTime = performance.now();
|
||
frame = requestAnimationFrame(tick);
|
||
}
|
||
return this;
|
||
}
|
||
|
||
// すぐにその値にする(ドラッグ中に指へ 1:1 で付いていくときもこれ)
|
||
set(value) {
|
||
running.delete(this);
|
||
this.value = this.target = value;
|
||
this.velocity = 0;
|
||
if (this.onUpdate) this.onUpdate(value);
|
||
return this;
|
||
}
|
||
|
||
// 画面上のいまの位置で止める(動いている最中につかんだとき)
|
||
stop() {
|
||
running.delete(this);
|
||
this.target = this.value;
|
||
this.velocity = 0;
|
||
this.onRest = null;
|
||
return this;
|
||
}
|
||
|
||
step(dt) {
|
||
// 硬いばねでも発散しないよう 1/240 秒刻みで積分する
|
||
const n = Math.max(1, Math.ceil(dt * 240));
|
||
const h = dt / n;
|
||
for (let i = 0; i < n; i++) {
|
||
const force = -this.stiffness * (this.value - this.target) - this.friction * this.velocity;
|
||
this.velocity += force * h;
|
||
this.value += this.velocity * h;
|
||
}
|
||
const settled =
|
||
Math.abs(this.value - this.target) < this.precision && Math.abs(this.velocity) < this.precision * 10;
|
||
if (settled) {
|
||
this.value = this.target;
|
||
this.velocity = 0;
|
||
running.delete(this);
|
||
}
|
||
if (this.onUpdate) this.onUpdate(this.value);
|
||
if (settled) this.rest();
|
||
}
|
||
|
||
rest() {
|
||
const fn = this.onRest;
|
||
this.onRest = null;
|
||
if (fn) fn();
|
||
}
|
||
}
|
||
|
||
// ---------- 指の動き ----------
|
||
// 直近 100ms の位置から速度(px/秒)を求める。離す直前に指を止めていれば速度は 0 になる
|
||
class VelocityTracker {
|
||
constructor() {
|
||
this.samples = [];
|
||
}
|
||
reset() {
|
||
this.samples.length = 0;
|
||
}
|
||
add(x, y, t = performance.now()) {
|
||
this.samples.push({ x, y, t });
|
||
while (this.samples.length > 2 && t - this.samples[0].t > 100) this.samples.shift();
|
||
}
|
||
velocity() {
|
||
const s = this.samples;
|
||
if (s.length < 2) return { x: 0, y: 0 };
|
||
const a = s[0];
|
||
const b = s[s.length - 1];
|
||
const dt = (b.t - a.t) / 1000;
|
||
return dt > 0 ? { x: (b.x - a.x) / dt, y: (b.y - a.y) / dt } : { x: 0, y: 0 };
|
||
}
|
||
}
|
||
|
||
// 勢いで進む距離(スクロールの減速と同じ指数関数の形)。0.998 = 普通、0.99 = 早く止まる
|
||
function project(velocity, decelerationRate = 0.998) {
|
||
return ((velocity / 1000) * decelerationRate) / (1 - decelerationRate);
|
||
}
|
||
|
||
// 端を越えた分は、越えるほど付いてこなくなる(いきなり止めない)
|
||
function rubberband(overshoot, dimension, constant = 0.55) {
|
||
return (overshoot * dimension * constant) / (dimension + constant * Math.abs(overshoot));
|
||
}
|
||
|
||
const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), textarea, select, [tabindex]:not([tabindex="-1"])';
|
||
|
||
function trapFocus(box, e) {
|
||
const list = [...box.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetParent !== null);
|
||
if (!list.length) return;
|
||
const first = list[0];
|
||
const last = list[list.length - 1];
|
||
if (!box.contains(document.activeElement)) {
|
||
e.preventDefault();
|
||
first.focus();
|
||
} else if (e.shiftKey && document.activeElement === first) {
|
||
e.preventDefault();
|
||
last.focus();
|
||
} else if (!e.shiftKey && document.activeElement === last) {
|
||
e.preventDefault();
|
||
first.focus();
|
||
}
|
||
}
|
||
|
||
// ---------- シート ----------
|
||
// root は背景(.sheet-backdrop)、中に .sheet。
|
||
// [data-sheet-drag](つまみ・見出し)をつかむと指に 1:1 で付いてくる。離したときは速度から止まる位置を
|
||
// 先読みし、シートの半分より下なら閉じる(閉じる途中でつかみ直すこともできる)。
|
||
// opts: { dismissible(false なら閉じられない), focus(開いたときにフォーカスする要素のセレクタ), onClose, onClosed }
|
||
function Sheet(root, opts = {}) {
|
||
const sheet = root.querySelector('.sheet');
|
||
const dismissible = opts.dismissible !== false;
|
||
const y = new Spring(0, { precision: 0.5 });
|
||
const tracker = new VelocityTracker();
|
||
let height = 0;
|
||
let isOpen = false;
|
||
let prevFocus = null;
|
||
let drag = null;
|
||
let downOnBackdrop = false;
|
||
|
||
const measure = () => {
|
||
height = sheet.offsetHeight + 24; // 影まで画面の外へ出す
|
||
};
|
||
|
||
y.onUpdate = (v) => {
|
||
sheet.style.transform = v ? `translate3d(0, ${v.toFixed(2)}px, 0)` : '';
|
||
root.style.setProperty('--sheet-progress', height ? clamp(1 - v / height, 0, 1).toFixed(3) : '1');
|
||
};
|
||
|
||
function onKey(e) {
|
||
if (e.key === 'Escape' && dismissible) {
|
||
e.preventDefault();
|
||
close();
|
||
} else if (e.key === 'Tab') {
|
||
trapFocus(sheet, e);
|
||
}
|
||
}
|
||
|
||
function open() {
|
||
if (isOpen) return;
|
||
isOpen = true;
|
||
if (root.hidden) {
|
||
root.hidden = false;
|
||
measure();
|
||
y.set(height);
|
||
}
|
||
y.to(0, { damping: 1, response: 0.38 });
|
||
prevFocus = document.activeElement;
|
||
document.addEventListener('keydown', onKey);
|
||
const target = opts.focus ? sheet.querySelector(opts.focus) : null;
|
||
(target || sheet).focus({ preventScroll: true });
|
||
}
|
||
|
||
function close(velocity) {
|
||
if (!isOpen) return;
|
||
isOpen = false;
|
||
document.removeEventListener('keydown', onKey);
|
||
measure();
|
||
y.to(height, {
|
||
damping: 1,
|
||
response: 0.3,
|
||
velocity: typeof velocity === 'number' ? velocity : undefined,
|
||
onRest: () => {
|
||
if (isOpen) return;
|
||
root.hidden = true;
|
||
if (opts.onClosed) opts.onClosed();
|
||
},
|
||
});
|
||
if (prevFocus && prevFocus.focus && document.contains(prevFocus)) prevFocus.focus({ preventScroll: true });
|
||
if (opts.onClose) opts.onClose();
|
||
}
|
||
|
||
root.addEventListener('pointerdown', (e) => {
|
||
downOnBackdrop = e.target === root;
|
||
});
|
||
root.addEventListener('click', (e) => {
|
||
if (dismissible && e.target === root && downOnBackdrop) close();
|
||
});
|
||
|
||
sheet.addEventListener('pointerdown', (e) => {
|
||
if (!dismissible || root.hidden || (e.pointerType === 'mouse' && e.button !== 0)) return;
|
||
if (!e.target.closest('[data-sheet-drag]')) return;
|
||
drag = { id: e.pointerId, x: e.clientX, y: e.clientY, origin: 0, active: false };
|
||
tracker.reset();
|
||
tracker.add(e.clientX, e.clientY, e.timeStamp);
|
||
});
|
||
sheet.addEventListener('pointermove', (e) => {
|
||
if (!drag || e.pointerId !== drag.id) return;
|
||
tracker.add(e.clientX, e.clientY, e.timeStamp);
|
||
if (!drag.active) {
|
||
const dx = e.clientX - drag.x;
|
||
const dy = e.clientY - drag.y;
|
||
if (Math.hypot(dx, dy) < 8) return;
|
||
if (Math.abs(dx) > Math.abs(dy)) {
|
||
drag = null;
|
||
return;
|
||
}
|
||
// ここで初めてつかむ。動いている最中なら画面上のいまの位置から
|
||
drag.active = true;
|
||
drag.y = e.clientY;
|
||
y.stop();
|
||
drag.origin = y.value;
|
||
measure();
|
||
sheet.setPointerCapture(e.pointerId);
|
||
sheet.classList.add('dragging');
|
||
if (!isOpen) {
|
||
isOpen = true;
|
||
document.addEventListener('keydown', onKey);
|
||
}
|
||
}
|
||
const raw = drag.origin + (e.clientY - drag.y);
|
||
y.set(raw < 0 ? -rubberband(-raw, height) : raw);
|
||
});
|
||
const end = (e) => {
|
||
if (!drag || e.pointerId !== drag.id) return;
|
||
const wasActive = drag.active;
|
||
drag = null;
|
||
sheet.classList.remove('dragging');
|
||
if (!wasActive) return;
|
||
tracker.add(e.clientX, e.clientY, e.timeStamp);
|
||
const v = tracker.velocity().y;
|
||
if (e.type !== 'pointercancel' && y.value + project(v) > height * 0.5) close(v);
|
||
// 払った勢いがあるので少しだけ弾ませて戻す
|
||
else y.to(0, { damping: 0.8, response: 0.3, velocity: v });
|
||
};
|
||
sheet.addEventListener('pointerup', end);
|
||
sheet.addEventListener('pointercancel', end);
|
||
|
||
return {
|
||
open,
|
||
close,
|
||
get isOpen() {
|
||
return isOpen;
|
||
},
|
||
};
|
||
}
|
||
|
||
// ---------- セグメント ----------
|
||
// container の子(label > input[type=radio] か、.active の付いた button)を選ぶつまみを滑らせる。
|
||
// 選ばれている所を押すとつまみが少し縮み、そのまま横へドラッグすると指に付いてくる。
|
||
// 離すと速度から止まる位置を先読みして、一番近い選択肢を選ぶ
|
||
function Segmented(container) {
|
||
const thumb = document.createElement('span');
|
||
thumb.className = 'segmented-thumb';
|
||
thumb.setAttribute('aria-hidden', 'true');
|
||
container.prepend(thumb);
|
||
container.classList.add('has-thumb');
|
||
|
||
const x = new Spring(0, { precision: 0.1 });
|
||
const w = new Spring(0, { precision: 0.1 });
|
||
const press = new Spring(1, { precision: 0.001 });
|
||
const tracker = new VelocityTracker();
|
||
let placed = false;
|
||
let drag = null;
|
||
let suppressClick = false;
|
||
|
||
const paint = () => {
|
||
thumb.style.width = `${w.value.toFixed(2)}px`;
|
||
thumb.style.transform = `translate3d(${x.value.toFixed(2)}px, 0, 0) scale(${press.value.toFixed(4)})`;
|
||
};
|
||
x.onUpdate = w.onUpdate = press.onUpdate = paint;
|
||
|
||
const items = () => [...container.children].filter((el) => el !== thumb && !el.hidden);
|
||
const isSelected = (el) =>
|
||
el.matches('label') ? Boolean(el.querySelector('input:checked')) : el.classList.contains('active');
|
||
const center = (el) => el.offsetLeft + el.offsetWidth / 2;
|
||
|
||
function sync(animate = true) {
|
||
if (drag && drag.active) return;
|
||
const el = items().find(isSelected);
|
||
thumb.hidden = !el;
|
||
if (!el || !el.offsetWidth) return; // 非表示の間は測れない(表示されたら ResizeObserver で合わせる)
|
||
if (animate && placed) {
|
||
if (x.target !== el.offsetLeft) x.to(el.offsetLeft, { damping: 1, response: 0.32 });
|
||
if (w.target !== el.offsetWidth) w.to(el.offsetWidth, { damping: 1, response: 0.32 });
|
||
} else {
|
||
x.set(el.offsetLeft);
|
||
w.set(el.offsetWidth);
|
||
}
|
||
placed = true;
|
||
}
|
||
|
||
function select(el) {
|
||
if (isSelected(el)) return;
|
||
if (el.matches('label')) {
|
||
const input = el.querySelector('input');
|
||
input.checked = true;
|
||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||
} else {
|
||
el.click();
|
||
}
|
||
}
|
||
|
||
container.addEventListener('pointerdown', (e) => {
|
||
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||
const el = items().find((i) => i.contains(e.target));
|
||
if (!el || !isSelected(el)) return;
|
||
drag = { id: e.pointerId, x: e.clientX, origin: 0, active: false };
|
||
tracker.reset();
|
||
tracker.add(e.clientX, 0, e.timeStamp);
|
||
press.to(0.95, { damping: 1, response: 0.2 });
|
||
});
|
||
container.addEventListener('pointermove', (e) => {
|
||
if (!drag || e.pointerId !== drag.id) return;
|
||
tracker.add(e.clientX, 0, e.timeStamp);
|
||
if (!drag.active) {
|
||
if (Math.abs(e.clientX - drag.x) < 6) return;
|
||
drag.active = true;
|
||
drag.x = e.clientX;
|
||
x.stop();
|
||
drag.origin = x.value;
|
||
container.setPointerCapture(e.pointerId);
|
||
}
|
||
const list = items();
|
||
const last = list[list.length - 1];
|
||
const min = list[0].offsetLeft;
|
||
const max = last.offsetLeft + last.offsetWidth - w.value;
|
||
let raw = drag.origin + (e.clientX - drag.x);
|
||
if (raw < min) raw = min - rubberband(min - raw, container.offsetWidth);
|
||
else if (raw > max) raw = max + rubberband(raw - max, container.offsetWidth);
|
||
x.set(raw);
|
||
});
|
||
const end = (e) => {
|
||
if (!drag || e.pointerId !== drag.id) return;
|
||
const wasActive = drag.active;
|
||
drag = null;
|
||
press.to(1, { damping: 1, response: 0.25 });
|
||
if (!wasActive) return;
|
||
suppressClick = true;
|
||
setTimeout(() => {
|
||
suppressClick = false;
|
||
}, 0);
|
||
tracker.add(e.clientX, 0, e.timeStamp);
|
||
const v = tracker.velocity().x;
|
||
const aim = x.value + w.value / 2 + project(v, 0.99);
|
||
const best = items().reduce((a, b) => (Math.abs(center(b) - aim) < Math.abs(center(a) - aim) ? b : a));
|
||
x.to(best.offsetLeft, { damping: 0.8, response: 0.3, velocity: v });
|
||
w.to(best.offsetWidth, { damping: 1, response: 0.3 });
|
||
select(best);
|
||
};
|
||
container.addEventListener('pointerup', end);
|
||
container.addEventListener('pointercancel', end);
|
||
// ドラッグで選んだ後のクリックで、指を離した所の選択肢まで選ばれないように
|
||
container.addEventListener(
|
||
'click',
|
||
(e) => {
|
||
if (!suppressClick) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
},
|
||
true
|
||
);
|
||
container.addEventListener('change', () => sync());
|
||
// ボタンの .active はクリックの処理で付け替わるので、描画の直前に合わせる
|
||
container.addEventListener('click', () => requestAnimationFrame(() => sync()));
|
||
if ('ResizeObserver' in window) new ResizeObserver(() => sync(false)).observe(container);
|
||
sync(false);
|
||
|
||
return { update: sync };
|
||
}
|
||
|
||
// ---------- 出し入れ ----------
|
||
// 要素を少し小さく透明な所からばねで出し、消すときは同じ道を戻る(hidden は消え終わってから付ける)。
|
||
// from: { scale, y }。視差効果を減らす設定では透明度だけ変える
|
||
function presence(el, show, from = {}) {
|
||
let p = el._presence;
|
||
if (!p) {
|
||
p = el._presence = new Spring(show ? 1 : 0, { precision: 0.002 });
|
||
el.hidden = !show;
|
||
}
|
||
const scale = from.scale ?? 0.96;
|
||
const dy = from.y ?? 8;
|
||
p.onUpdate = (v) => {
|
||
el.style.opacity = v >= 1 ? '' : v.toFixed(3);
|
||
el.style.transform =
|
||
v >= 1 || reducedMotion()
|
||
? ''
|
||
: `translate3d(0, ${((1 - v) * dy).toFixed(2)}px, 0) scale(${(scale + (1 - scale) * v).toFixed(4)})`;
|
||
};
|
||
if (show) {
|
||
if (el.hidden) {
|
||
el.hidden = false;
|
||
p.set(0);
|
||
}
|
||
if (p.target !== 1) p.to(1, { damping: 1, response: 0.32, allowReduced: true });
|
||
} else if (!el.hidden && p.target !== 0) {
|
||
p.to(0, {
|
||
damping: 1,
|
||
response: 0.22,
|
||
allowReduced: true,
|
||
onRest: () => {
|
||
el.hidden = true;
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
// 数字をばねで変える(ポット・スタックなど)
|
||
function countTo(el, value, format) {
|
||
const show = (v) => {
|
||
el.textContent = format(Math.round(v));
|
||
};
|
||
let s = el._count;
|
||
if (!s) {
|
||
s = el._count = new Spring(value, { precision: 0.5 });
|
||
s.onUpdate = show;
|
||
show(value);
|
||
return;
|
||
}
|
||
s.onUpdate = show;
|
||
if (s.target !== value) s.to(value, { damping: 1, response: 0.5 });
|
||
}
|
||
|
||
// 横に揺らす(鍵の入力ミスなど)。勢いを与えて、減衰するばねに任せる
|
||
function shake(el) {
|
||
if (reducedMotion()) return;
|
||
let s = el._shake;
|
||
if (!s) {
|
||
s = el._shake = new Spring(0, { precision: 0.05 });
|
||
s.onUpdate = (v) => {
|
||
el.style.transform = v ? `translate3d(${v.toFixed(2)}px, 0, 0)` : '';
|
||
};
|
||
}
|
||
s.to(0, { damping: 0.22, response: 0.26, velocity: -320 });
|
||
}
|
||
|
||
// 振動(対応している端末だけ。画面に触れる前は振動できない)
|
||
function haptic(pattern = 10) {
|
||
try {
|
||
const touched = !navigator.userActivation || navigator.userActivation.hasBeenActive;
|
||
if (navigator.vibrate && touched) navigator.vibrate(pattern);
|
||
} catch {
|
||
/* 振動できない環境 */
|
||
}
|
||
}
|
||
|
||
// ナビゲーションバー: 中身が下に潜り込んだら半透明の素材と境目のぼかしを出し、
|
||
// 大きな見出しが隠れたらバーの中に小さな見出しを出す
|
||
function navBar(nav, largeTitle) {
|
||
if (!nav) return;
|
||
const update = () => nav.classList.toggle('scrolled', window.scrollY > 2);
|
||
update();
|
||
window.addEventListener('scroll', update, { passive: true });
|
||
const inline = nav.querySelector('.nav-title');
|
||
if (!largeTitle || !inline) return;
|
||
const mirror = () => {
|
||
inline.textContent = largeTitle.textContent;
|
||
};
|
||
mirror();
|
||
new MutationObserver(mirror).observe(largeTitle, { childList: true, characterData: true, subtree: true });
|
||
if ('IntersectionObserver' in window) {
|
||
new IntersectionObserver(([entry]) => nav.classList.toggle('show-title', !entry.isIntersecting), {
|
||
rootMargin: `-${nav.offsetHeight}px 0px 0px 0px`,
|
||
}).observe(largeTitle);
|
||
}
|
||
}
|
||
|
||
window.Fluid = {
|
||
Spring,
|
||
VelocityTracker,
|
||
project,
|
||
rubberband,
|
||
clamp,
|
||
reducedMotion,
|
||
Sheet,
|
||
Segmented,
|
||
presence,
|
||
countTo,
|
||
shake,
|
||
haptic,
|
||
navBar,
|
||
};
|
||
})();
|