🌟

逆線形補間(unlerp)解説

に公開

対象:私の様に数学が苦手でもOK。Vue / React / Nuxt / Next / TypeScript / JavaScript でUI座標を扱う人
ゴール:unlerpで「いま区間の何%か」を出せるようになる(=px → % の橋渡しができる)

TL;DR(最短結論)

  • unlerpは「区間 [a, b] の中で値 xどれくらい進んだか」を 0〜1 で返す関数
    公式:t = (x - a) / (b - a)
  • UIでは ビューポートのpx → 親コンテナ内の% に直すのに超便利
  • getBoundingClientRect() と組み合わせるとスクロールにも強い

unlerp とは?

  • 物差しを思い浮かべましょう。0〜100cm のうち 23cm は 23%。式にすると 23 / 100 = 0.23
  • 一般化すると:(今の位置 - 始点) / (終点 - 始点)0〜1 の割合

基本関数

/** 区間 [a, b] の中で x が何割(0〜1)進んだかを返す */
export function unlerp(x: number, a: number, b: number): number {
  return (x - a) / (b - a);
}
  • /** ... */ … 何をする関数かコメント(自分や未来の自分のため)
  • export function unlerp(...) … 他ファイルからも使えるように公開
  • x: number … 今いる場所(測りたい値)
  • a: number, b: number … 始点と終点(左端と右端など)
  • return (x - a) / (b - a); … 「始点からどれだけ進んだか」を区間長で割る=0〜1 の割合

注意:a === b(区間長が0)のときは ゼロ除算になるので、実戦では安全版を使います↓


安全版:0除算と範囲外クランプ

/** 0〜1に丸める安全版 unlerp(a===b回避 & 範囲外は切り詰め) */
export function unlerp01(x: number, a: number, b: number): number {
  if (a === b) return 0;
  const t = (x - a) / (b - a);
  return Math.min(1, Math.max(0, t));
}
  • if (a === b) return 0; … 区間長が0なら 0 を返す(好みで 0.5 にしてもOK)
  • const t = ... … まず素の割合を計算
  • Math.min(1, Math.max(0, t))0〜1にクランプ(マーカーを枠内に収めたいとき便利)

実用ユーティリティ:点(px, py) → 親Rect内の%

/** 点(px,py)を親Rect内の%へ(オフセット微調整つき) */
export function toPercentInRect(
  px: number, py: number, rect: DOMRect,
  offsetXPct = 0, offsetYPct = 0, clamp = true
) {
  const tX = rect.width  ? (px - rect.left) / rect.width  : 0;
  const tY = rect.height ? (py - rect.top)  / rect.height : 0;
  let xPct = tX * 100 + offsetXPct;
  let yPct = tY * 100 + offsetYPct;
  if (clamp) {
    xPct = Math.min(100, Math.max(0, xPct));
    yPct = Math.min(100, Math.max(0, yPct));
  }
  return { xPct, yPct };
}
  • px, pyビューポート基準の座標(getBoundingClientRect()から求める点など)
  • rect: DOMRect … 親コンテナの Rect(同じくビューポート基準)
  • offsetXPct/offsetYPct … 最後に %単位で微調整するつまみ(見た目合わせが楽)
  • clamp = true … 0〜100%に収めるかどうかのフラグ
  • rect.width ? ... : 0 … 幅0/高0(未レイアウト)対策
  • tX, tYunlerp で 0〜1 の割合に変換
  • *100 + offset … %に直し、微調整を加算
  • clamp … 0〜100%に切り詰め(必要なときだけ)
  • return { xPct, yPct } … CSSにそのまま渡せる形で返す

実例:アンカー点 → 親%

前提(すべて getBoundingClientRect() の値=ビューポート基準

  • 親Rect:left=100, top=200, width=800, height=600
  • 子Rect:left=220, top=260, width=300, height=150
  • アンカー:anchorX=0.35, anchorY=0.25(子の左35%, 上25%)
  • オフセット:offsetXPct=-2, offsetYPct=+1
  1. 子アンカーの画面座標
const px = 220 + 300 * 0.35 = 325;
const py = 260 + 150 * 0.25 = 297.5;
  1. 親内での割合(unlerp)→ %
x% = ((325 - 100) / 800) * 100 = 28.125%;
y% = ((297.5 - 200) / 600) * 100 = 16.25%;
  1. 微調整
left = 28.125 - 2 = 26.125%;
top  = 16.25  + 1 = 17.25%;

スクロールしても 親と子を同じ基準(ビューポート)で差分を取るため、相殺されて安定します。


Vue 3(Composition API)

import { ref, onMounted, nextTick } from "vue";

const leftPct = ref(0);
const topPct  = ref(0);

onMounted(async () => {
  await nextTick(); // DOMサイズが確定するのを待つ

  const parent = document.querySelector(".container") as HTMLElement;
  const child  = document.querySelector(".child") as HTMLElement;
  if (!parent || !child) return; // 念のための存在チェック

  const pr = parent.getBoundingClientRect(); // 親Rect(ビューポート基準)
  const cr = child.getBoundingClientRect();  // 子Rect(同上)

  // 子の中央点(px,py)をとる
  const px = cr.left + cr.width  * 0.5;
  const py = cr.top  + cr.height * 0.5;

  // 親内の%へ(微調整は例として -2%, +1%)
  const { xPct, yPct } = toPercentInRect(px, py, pr, -2, +1);

  leftPct.value = xPct; // リアクティブに反映
  topPct.value  = yPct;
});
  • nextTick()描画完了後にRectを読む(0×0回避)
  • getBoundingClientRect()見た目基準の位置・サイズ(transform後の寸法にも対応)
  • toPercentInRect()unlerp → % → 微調整をひとまとめに

テンプレ側:

<div class="marker" :style="{ left: leftPct + '%', top: topPct + '%' }"></div>

React 版(useEffect)

import { useEffect, useRef } from "react";

export function Marker() {
  const markerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const parent = document.querySelector(".container") as HTMLElement;
    const child  = document.querySelector(".child") as HTMLElement;
    if (!parent || !child || !markerRef.current) return;

    const pr = parent.getBoundingClientRect();
    const cr = child.getBoundingClientRect();

    const px = cr.left + cr.width  * 0.5;
    const py = cr.top  + cr.height * 0.5;

    const { xPct, yPct } = toPercentInRect(px, py, pr);

    markerRef.current.style.left = `${xPct}%`;
    markerRef.current.style.top  = `${yPct}%`;
  }, []);

  return <div ref={markerRef} className="marker" />;
}
  • useEffect(..., []) … マウント後一度だけ実行(Rectが読めるタイミング)
  • style.left/top = % … そのままCSSに反映

よくあるハマりどころ(優先度順)

  1. 区間長が0(a===b, width/height=0)
    → レイアウト未確定。onMounted + nextTick(Vue)/ useEffect(React)で描画後に測る
    → 安全版 unlerp01 / toPercentInRect を使う

  2. display:none の要素を測っている
    → Rectは常に0×0。まず表示状態で測る(visibility:hiddenや画面外配置も可)

  3. transform の影響
    getBoundingClientRect()見た目(変形後)のサイズ
    → レイアウト寸法なら offsetWidth/Height
    使い分け

  4. %が 0〜100 をはみ出す
    → それでOKな演出もあるが、通常は clamp で切り詰める

  5. スクロール・ネストスクロール
    → 親子とも Rect(ビューポート基準)で差分を取れば相殺される


確認すべきファイル/コードと意図

  • HTML/テンプレ

    • .container は1つ・サイズが確定position: relative; overflow: hidden; 推奨)
    • .child は正しいセレクタで取得できる
  • CSS

    • .marker { position: absolute; }(%の基準を親にしたい)
    • レスポンシブ時も親の width/height が0にならないように
  • TS/JS

    • 測定はマウント後(Vue: onMounted+nextTick, React: useEffect
    • リサイズ時は 再測定ResizeObserver or window.onresize

参考(公式ドキュメント)


まとめ

  • unlerp = (x - a) / (b - a) で「今どれくらい進んだか(0〜1)」が取れる
  • px → % の変換にぴったり。getBoundingClientRect() とセットで使うとスクロールにも強い

Discussion