/** * 全ページ共通の演出(スクロール表示・文字演出・写真のスクロール追従) * - 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); })();