/**
* 全ページ共通の演出(スクロール表示・文字演出・写真のスクロール追従)
* - HTML 側の data-reveal / data-text-reveal / data-parallax 属性が付いた要素だけが対象
* - html.is-motion(
のインラインスクリプトが付ける)がある時だけ動かす。
* JavaScript 無効・動きを抑える設定(prefers-reduced-motion: reduce)の時は何もせず、最初から全て表示される
* - スクロール表示: [data-reveal] の上端が画面高の 85% に入ったら is-shown を付けて 1 回だけ表示する(見た目は _motion.scss)
* 上に戻っても隠さない。途中の位置で再読み込みした時、すでに通り過ぎた要素もすぐ表示する
* - 文字演出: [data-text-reveal="copy" | "label" | "title"] を、行ごとの枠(マスク)の下から文字・行がせり上がるように表示する
* 再生中だけ文字・行を span に分割し、終わったら元の HTML に戻す(折り返し・禁則・読み上げを元のまま保つため)
* - スクロール追従: [data-parallax] の中の写真を、PC のみ +12px → -12px の範囲で動かす(枠は固定)
*/
(() => {
"use strict";
const ROOT_CLASS = "is-motion";
// Foundation/_mediaquery.scss の tab(max-width: 1023px)の反対
const MEDIA_QUERY_PC = "(min-width: 1024px)";
// 上端が画面高の 85% に達したら表示
const REVEAL_LINE = 0.85;
// 見出し → 本文 → 写真・カードの順に 70ms 刻み、最大 210ms(PC のみ)
const STEP_DELAY = 70;
const MAX_STEP_DELAY = 210;
// スクロール表示の所要時間(_motion.scss の 600ms)より少し長い、後片付けまでの上限
const REVEAL_DURATION_LIMIT = 1000;
// 主見出しは英語ラベルの 150ms 後、本文は主見出しの 200ms 後に開始(PC のみ)
const TITLE_DELAY_AFTER_LABEL = 150;
const BODY_DELAY_AFTER_TITLE = 200;
const TEXT_EASING = "cubic-bezier(0.22, 1, 0.36, 1)";
const TEXT_OFFSET = "110%";
// 文字演出の指定値(unit: 文字ごと / 行ごと、gap: 文字間・行間の時間差、lineGap: 文字ごとの時に次の行を始めるまでの時間差)
const TEXT_SETTINGS = {
// メインコピー(#mv-title)
copy: {
pc: { unit: "char", duration: 700, gap: 18, lineGap: 160 },
sp: { unit: "line", duration: 600, gap: 80 },
},
// 英語セクション名(ABOUT FAMOUS 等)
label: {
pc: { unit: "char", duration: 600, gap: 24, lineGap: 0 },
sp: { unit: "line", duration: 600, gap: 0 },
},
// 日本語の主見出し(#about-title 等)
title: {
pc: { unit: "line", duration: 800, gap: 100 },
sp: { unit: "line", duration: 600, gap: 80 },
},
};
// Web フォントの読み込みを待つ上限(行の計算をフォント適用後に行うため)
const FONT_WAIT_LIMIT = 800;
const PARALLAX_RANGE = 12;
const rootElement = document.documentElement;
if (!rootElement.classList.contains(ROOT_CLASS)) return;
// のインラインスクリプトに、初期化できたことを伝える(伝わらない時は 3 秒後に全て表示される)
window.famousMotionReady = true;
const mediaQueryPc = window.matchMedia(MEDIA_QUERY_PC);
/* 文字演出
---------------------------------------- */
// 表示上の行ごとに文字列を取り出す(DOM を変えずに Range で各文字の位置を測るので、禁則処理や
の結果がそのまま反映される)
function measureLines(element) {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
const range = document.createRange();
const lines = [];
let lastTop = null;
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
let offset = 0;
for (const character of node.data) {
range.setStart(node, offset);
range.setEnd(node, offset + character.length);
offset += character.length;
const rect = range.getBoundingClientRect();
// タグ間の改行・空白など、表示されていない文字は飛ばす
if (rect.width === 0 && rect.height === 0) continue;
if (lastTop === null || Math.abs(rect.top - lastTop) > rect.height / 2) {
lines.push("");
lastTop = rect.top;
}
lines[lines.length - 1] += character;
}
}
return lines.map((line) => line.trim()).filter((line) => line !== "");
}
function createMask(className, text) {
const mask = document.createElement("span");
const inner = document.createElement("span");
mask.className = className;
// 内側のクラス名は固有にする(.inner など汎用の名前だと、.c-page-mv .inner のようなページ側のスタイルが当たってしまう)
inner.className = `${className}-text`;
inner.textContent = text;
mask.append(inner);
return { mask, inner };
}
// 行ごとに分割し、動かす要素と開始時間の一覧を返す
function splitIntoLines(element, setting) {
const lines = measureLines(element);
const items = lines.map((line, index) => {
const { mask, inner } = createMask("c-motion-line", line);
return { mask, inner, delay: index * setting.gap };
});
element.replaceChildren(...items.map((item) => item.mask));
return items;
}
// 文字ごとに分割する(.phrase などの入れ子は残すので、文節単位の折り返しは変わらない)
function splitIntoCharacters(element, setting) {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
const textNodes = [];
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
// タグ間の改行・空白だけのノードはそのまま残す
if (node.data.trim() !== "") textNodes.push(node);
}
const items = [];
textNodes.forEach((node) => {
const fragment = document.createDocumentFragment();
for (const character of node.data) {
const item = createMask("c-motion-char", character);
fragment.append(item.mask);
items.push(item);
}
node.replaceWith(fragment);
});
// 表示上の行を求め、行内は文字間(gap)、次の行は lineGap 遅らせて開始する
let lineIndex = -1;
let indexInLine = 0;
let lastTop = null;
items.forEach((item) => {
const top = item.mask.offsetTop;
if (lastTop === null || Math.abs(top - lastTop) > item.mask.offsetHeight / 2) {
lineIndex += 1;
indexInLine = 0;
lastTop = top;
}
item.delay = lineIndex * setting.lineGap + indexInLine * setting.gap;
indexInLine += 1;
});
return items;
}
function playText(element, startDelay) {
const settings = TEXT_SETTINGS[element.dataset.textReveal];
if (!settings || typeof element.animate !== "function") {
element.classList.add("is-played");
return;
}
const setting = mediaQueryPc.matches ? settings.pc : settings.sp;
const originalHtml = element.innerHTML;
const isHeading = /^H[1-6]$/.test(element.tagName);
// 分割中も、読み上げでは元の文章を 1 回だけ読めるようにする
if (isHeading) element.setAttribute("aria-label", element.textContent.replace(/\s+/g, " ").trim());
function restore() {
element.innerHTML = originalHtml;
if (isHeading) element.removeAttribute("aria-label");
}
try {
const items = setting.unit === "char" ? splitIntoCharacters(element, setting) : splitIntoLines(element, setting);
const animations = items.map((item) =>
item.inner.animate([{ transform: `translateY(${TEXT_OFFSET})` }, { transform: "translateY(0)" }], {
duration: setting.duration,
delay: startDelay + item.delay,
easing: TEXT_EASING,
fill: "backwards",
})
);
element.classList.add("is-played");
Promise.all(animations.map((animation) => animation.finished)).then(restore, restore);
} catch (error) {
// 分割に失敗した時は、演出なしで元の文章を表示する
restore();
element.classList.add("is-played");
}
}
/* スクロール表示
---------------------------------------- */
// [data-reveal-stagger] の中のカードは、同じ行の中で左から右へ 70ms ずつ遅らせる
function getStaggerDelay(element, shownElements) {
const parent = element.parentElement;
if (!parent || !parent.hasAttribute("data-reveal-stagger")) return 0;
const top = element.getBoundingClientRect().top;
const indexInRow = shownElements.filter(
(other) =>
other !== element &&
other.parentElement === parent &&
Math.abs(other.getBoundingClientRect().top - top) < 1 &&
other.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING
).length;
return Math.min(indexInRow * STEP_DELAY, MAX_STEP_DELAY);
}
// 表示し終えたら演出用の属性・クラスを外し、要素を元の状態に戻す(リンクボタン本来のホバー時の transition などを妨げないため)
function finishReveal(element, delay) {
let timerId = 0;
function finish() {
window.clearTimeout(timerId);
element.removeEventListener("transitionend", handleTransitionEnd);
element.removeAttribute("data-reveal");
element.classList.remove("is-shown");
element.style.removeProperty("--reveal-delay");
}
function handleTransitionEnd(event) {
if (event.target === element && event.propertyName === "opacity") finish();
}
element.addEventListener("transitionend", handleTransitionEnd);
// transitionend が来ない時(画面外で省略された時など)の保険
timerId = window.setTimeout(finish, delay + REVEAL_DURATION_LIMIT);
}
// 同時に画面内へ入った要素を、セクションごとに「英語ラベル → 主見出し → 本文 → 写真・カード」の順で表示する
function showGroup(elements) {
const isPc = mediaQueryPc.matches;
const texts = elements.filter((element) => element.hasAttribute("data-text-reveal"));
const reveals = elements.filter((element) => element.hasAttribute("data-reveal"));
const hasLabel = texts.some((element) => element.dataset.textReveal === "label");
const headings = texts.filter((element) => element.dataset.textReveal !== "label");
let headingDelay = 0;
texts.forEach((element) => {
let delay = 0;
if (isPc && element.dataset.textReveal !== "label") {
delay = Number(element.dataset.textRevealDelay) || (hasLabel ? TITLE_DELAY_AFTER_LABEL : 0);
headingDelay = Math.max(headingDelay, delay);
}
playText(element, delay);
});
const baseDelay = headings.length > 0 ? headingDelay + BODY_DELAY_AFTER_TITLE : 0;
// data-reveal-order: 1 = 本文、2 = 写真・カード。指定なし(番号・工程・説明項目など)は見出しを待たずにすぐ表示する
const orders = reveals.map((element) => Number(element.dataset.revealOrder) || 0);
const minOrder = Math.min(...orders.filter((order) => order > 0));
reveals.forEach((element, index) => {
// 1023px 以下は遅延なし(_motion.scss でも --reveal-delay を使わない)
const stepDelay = Math.min((orders[index] - minOrder) * STEP_DELAY, MAX_STEP_DELAY);
const orderDelay = orders[index] > 0 ? baseDelay + stepDelay : 0;
const delay = isPc ? orderDelay + getStaggerDelay(element, reveals) : 0;
element.style.setProperty("--reveal-delay", `${delay}ms`);
element.classList.add("is-shown");
finishReveal(element, delay);
});
}
function createReveal() {
const pending = new Set(document.querySelectorAll("[data-reveal], [data-text-reveal]"));
if (pending.size === 0) return { destroy: () => {} };
const controller = new AbortController();
const { signal } = controller;
let frameId = 0;
function show(elements) {
const targets = elements.filter((element) => pending.has(element));
if (targets.length === 0) return;
const groups = new Map();
targets.forEach((element) => {
pending.delete(element);
observer.unobserve(element);
const section = element.closest("section") || document.body;
if (!groups.has(section)) groups.set(section, []);
groups.get(section).push(element);
});
groups.forEach((group) => showGroup(group));
if (pending.size === 0) destroy();
}
const observer = new IntersectionObserver(
(entries) => {
const line = window.innerHeight * REVEAL_LINE;
// すでに通り過ぎた要素(途中の位置で再読み込みした時など)も表示する
show(entries.filter((entry) => entry.isIntersecting || entry.boundingClientRect.top < line).map((entry) => entry.target));
},
{ rootMargin: `0px 0px -${Math.round((1 - REVEAL_LINE) * 100)}% 0px` }
);
// 速いスクロールやページ内リンクで、判定範囲を一気に飛び越えた要素は IntersectionObserver に通知されないため、
// スクロール時に「画面より上へ通り過ぎた要素」も表示する
function showPassed() {
frameId = 0;
show([...pending].filter((element) => element.getBoundingClientRect().bottom < 0));
}
window.addEventListener(
"scroll",
() => {
if (frameId === 0) frameId = window.requestAnimationFrame(showPassed);
},
{ passive: true, signal }
);
function destroy() {
controller.abort();
observer.disconnect();
if (frameId !== 0) window.cancelAnimationFrame(frameId);
}
pending.forEach((element) => observer.observe(element));
return { destroy };
}
/* 写真のスクロール追従(PC のみ)
---------------------------------------- */
// セクション上端が画面下端に入った時を 0、セクション下端が画面上端を抜ける時を 1 として、写真を +12px → -12px へ動かす
function createParallax(frameElement) {
const section = frameElement.closest("section");
if (!section) return { destroy: () => {} };
const controller = new AbortController();
const { signal } = controller;
let isInView = false;
let frameId = 0;
function isEnabled() {
return mediaQueryPc.matches;
}
function render() {
frameId = 0;
if (!isEnabled()) {
frameElement.classList.remove("is-parallax");
frameElement.style.removeProperty("--parallax-y");
return;
}
const rect = section.getBoundingClientRect();
const progress = Math.min(Math.max((window.innerHeight - rect.top) / (window.innerHeight + rect.height), 0), 1);
frameElement.classList.add("is-parallax");
frameElement.style.setProperty("--parallax-y", `${(PARALLAX_RANGE - PARALLAX_RANGE * 2 * progress).toFixed(2)}px`);
}
// 更新は requestAnimationFrame でまとめる。画面外にある間は更新しない
function requestRender() {
if (frameId !== 0 || (!isInView && isEnabled())) return;
frameId = window.requestAnimationFrame(render);
}
const observer = new IntersectionObserver((entries) => {
isInView = entries.some((entry) => entry.isIntersecting);
requestRender();
});
observer.observe(section);
window.addEventListener("scroll", requestRender, { passive: true, signal });
window.addEventListener("resize", requestRender, { signal });
mediaQueryPc.addEventListener("change", requestRender, { signal });
render();
return {
destroy: () => {
controller.abort();
observer.disconnect();
if (frameId !== 0) window.cancelAnimationFrame(frameId);
frameElement.classList.remove("is-parallax");
frameElement.style.removeProperty("--parallax-y");
},
};
}
document.querySelectorAll("[data-parallax]").forEach((frameElement) => createParallax(frameElement));
// 行の計算は Web フォントの適用後に行う(読み込みが遅い時は待たずに始める)
const fontsReady = document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve();
const timeLimit = new Promise((resolve) => window.setTimeout(resolve, FONT_WAIT_LIMIT));
Promise.race([fontsReady, timeLimit]).then(createReveal, createReveal);
})();