/* ui.jsx — 共通UI部品(v4) */
const E = window.UWEngine;
const fmt = (n, sign) => n == null ? '—' : (sign && n > 0 ? '+' : '') + (Math.round(n * 10) / 10).toLocaleString('ja-JP');
const yen = (n) => '¥' + Math.round(n).toLocaleString('ja-JP');
const gTxt = (v, sign) => v == null ? T("-") : fmt(Math.round(v * 10) / 10, sign) + 'g';
/* 数字がテレテレと変わる(値が変わったときだけ短くアニメーション) */
function useCountUp(target, ms = 480) {
const [v, setV] = React.useState(target);
const from = React.useRef(target),tm = React.useRef([]);
React.useEffect(() => {
const a = from.current,b = target;tm.current.forEach(clearTimeout);tm.current = [];
if (a === b || document.hidden || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {from.current = b;setV(b);return;}
const N = 14;
for (let i = 1; i <= N; i++) tm.current.push(setTimeout(() => {const p = i / N,e = 1 - Math.pow(1 - p, 3),x = i === N ? b : a + (b - a) * e;from.current = x;setV(x);}, ms / N * i));
return () => tm.current.forEach(clearTimeout);
}, [target]);
return v;
}
function Num({ value, sign, unit = 'g', yenMode, className }) {
const v = useCountUp(value || 0);
const [bump, setBump] = React.useState(0);const prev = React.useRef(value);
React.useEffect(() => {if (prev.current !== value) {setBump((b) => b + 1);prev.current = value;}}, [value]);
return {yenMode ? yen(v) : <>{fmt(Math.round(v), sign)}{unit && {unit}}>};
}
function Step({ value, min = 0, max = 99, step = 1, onChange }) {
return (
e.stopPropagation()}>
{value}
);
}
function Toggle({ checked, disabled, onChange, children, title }) {
return (
);
}
function Chips({ options, value, onPick, sm, qty }) {
return (
{options.map((o) =>
)}
);
}
/* 製品POP(名前を押すと表示。リンクは直接遷移せず、ここから開く) */
const PopCtx = React.createContext(() => {});
/* PName:製品名を押すと製品POP。edit=「変更する」で開くコンポーネント(内訳から開いたとき) */
function PName({ id, children, className, edit }) {
const open = React.useContext(PopCtx);
if (!id) return {children};
return ;
}
/* ===== 製品の選択回数の集計 =====
* 製品カード・追加ボタンを押すたびに記録する。
* 1) Google アナリティクス(サイト共通の gtag)にイベント「uw_select」を送る … 全ユーザーの集計はこちら(GA4 の「イベント」→ uw_select)
* item_id=製品名(日本語名。EN版でも日本語名にそろえて集計できるようにする)/item_code=製品ID/uw_action/uw_system/uw_lang
* 2) このブラウザの localStorage にも回数を保存 … 開発用バーの「選択回数」で確認(自分の操作の確認用)
* gtag が無い環境(ローカルなど)では 1) は何もしない。 */
const TRACK_KEY = (window.UW_STORE || 'uw_jp') + '_select_counts';
function uwTrack(id, action, system) {
if (!id) return;
const r = (E.info(id) || {}).row || {},name = r.name_ja || (E.info(id) || {}).name || id; // 集計のキーは製品名(日本語名)
try {const c = JSON.parse(localStorage.getItem(TRACK_KEY) || '{}');c[name] = (c[name] || 0) + 1;localStorage.setItem(TRACK_KEY, JSON.stringify(c));} catch (e) {}
try {if (typeof window.gtag === 'function') window.gtag('event', 'uw_select', { item_id: name, item_name: name, item_code: id, uw_action: action || 'select', uw_system: system || '', uw_lang: window.UW_LANG || 'ja' });} catch (e) {}
}
function uwTrackCounts() {try {return JSON.parse(localStorage.getItem(TRACK_KEY) || '{}');} catch (e) {return {};}}
function ProductPop({ id, onClose, priceMode, onEdit }) {
React.useEffect(() => {const f = (e) => e.key === 'Escape' && onClose();addEventListener('keydown', f);return () => removeEventListener('keydown', f);}, []);
const i = E.info(id),inside = i.table === 'cameras' || i.table === 'lenses' || ['gear', 'converter_ttl', 'converter_manual'].includes(i.cat);
return (
e.stopPropagation()}>
{i.img ?

: i.noPhoto ? null :
{T("画像準備中")}}
{i.pn || T("製品番号 未登録")}
{i.name}
| {T("陸上重量")} | {gTxt(i.w)} |
{!inside && | {T("水中重量(淡水)")} | {gTxt(i.u, true)} |
}
{i.note && | ※ | {i.note} |
}
{priceMode && E.SHOW_PRICE && | {T("価格(税込)")} | {i.price == null ? T("-") : yen(i.price * E.TAX)} |
}
);
}
/* カード:左に製品画像(DBの image_url)、右に名称・説明・重量 */
/* act=カード右側のボタン { label, onClick, disabled }。カードを押したときも同じ動作。押すと選択回数を記録(uwTrack) */
function Card({ id, on, dis, onClick, title, desc, rec, none, noWeight, children, badge, onRemove, acts, act }) {
const i = id ? E.info(id) : null;
const click0 = onClick;
onClick = click0 && id ? () => {if (!on) uwTrack(id, 'card');click0();} : click0;
if (act && !acts) acts = ;
const inside = i && (i.table === 'cameras' || i.table === 'lenses' || ['gear', 'converter_ttl', 'converter_manual'].includes(i.cat));
return (
{if (!dis && (e.key === 'Enter' || e.key === ' ')) {e.preventDefault();onClick && onClick();}}}>
{onRemove &&
}
{none || i && i.noPhoto ? none ? '—' : '' : i && i.img ?

{e.target.style.display = 'none';}} /> :
{T("画像")}
{T("準備中")}}
{i && !title ?
{i.name} : title}{rec &&
{T("おすすめ")}}{badge}
{i && i.pn &&
{i.pn}
}
{desc &&
{desc}
}
{i && !noWeight &&
{T("陸上")} {gTxt(i.w)}{!inside && <> {T("水中")} {gTxt(i.u, true)}>}
}
{children &&
e.stopPropagation()}>{children}
}
{acts &&
e.stopPropagation()}>{acts}
}
);
}
/* フロート:長さごとの上限本数と、混ぜる場合の組み合わせを分けて表示 */
function capSummary(size) {
const pats = E.CAP[size] || [];
const only = len => Math.max(0, ...pats.filter(p => p.every(x => x === len)).map(p => p.length));
const mixed = pats.filter(p => p.includes(40) && p.includes(65)).map(p => ({ n40: p.filter(x => x === 40).length, n65: p.filter(x => x === 65).length }));
const tot = Math.max(0, ...pats.map(p => p.reduce((a, b) => a + b, 0)));
return { m40: only(40), m65: only(65), mixed, tot };
}
function Bar({ n, len }) { return {Array.from({ length: n }).map((_, i) => )}; }
function FloatChart({ FL }) {
return <>
| {T("サイズ")} | {T("有効長")} | {T("長さ 40mm")} | {T("長さ 65mm")} | {T("長さを混ぜる場合")} |
{E.ARMS.map(id => { const s = E.armSize(id), c = capSummary(s), d = E.DIM[id] || {};
return | {s} {id} | {d.effective_mm ? d.effective_mm + 'mm' : '–'} |
{c.m40}{T("本まで")} |
{c.m65 ? c.m65 + T("本まで") : '–'} |
{c.mixed.length ? c.mixed.map((m, k) => 65×{m.n65} + 40×{m.n40} ) : '–'} |
; })}
{T("65mm は 40mm の約1.6本分の長さです。アームにおさまる合計の長さで判定しています。")} {T("複数のアームがある場合は、フロートを各アームに振り分けて装着できるかを判定します。")}
| {T("フロート")} | {T("長さ × 太さ")} | {T("陸上 / 水中")} |
{FL.map(f => | {f.name} {f.id} |
{(E.U[f.id] || {}).float_length} × φ{(E.U[f.id] || {}).float_width} | {gTxt(f.w)} / {gTxt(f.u, true)} |
)}
>;
}
function FiberChart() {
const arms = E.ARMS.map(id => ({ id, s: E.armSize(id), d: E.DIM[id] || {} }));
const GUIDE = { S: T("直付け~アームS 1本"), M: T("アームM 1本~S 2本"), L: T("アームL・M 2本以上") };
return <>
| {T("サイズ")} | {T("製品")} | {T("全長")} | {T("対応するアーム構成の目安")} |
{E.FIBER.map(f => { const i = E.info(f.pid), d = E.DIM[f.pid] || {};
return | {f.size} | {i.name} {i.pn} | {d.length_mm ? d.length_mm + 'mm' : '–'} | {GUIDE[f.size] || '–'} |
; })}
| {T("アーム")} | {T("全長")} | {T("有効長")} |
{arms.map(a => | {E.info(a.id).name} {a.id} | {a.d.length_mm ? a.d.length_mm + 'mm' : '–'} | {a.d.effective_mm ? a.d.effective_mm + 'mm' : '–'} |
)}
{T("ケーブルは、ハウジングの光出力口からストロボまでの距離(アームの有効長の合計+余裕)より長いものを選んでください。目安は実際の取り回しで変わります。")}
>;
}
function SizeChart({ kind, onClose }) {
React.useEffect(() => {const f = (e) => e.key === 'Escape' && onClose();addEventListener('keydown', f);return () => removeEventListener('keydown', f);}, []);
const FL = E.FLOATS.map((id) => E.info(id));
return (
e.stopPropagation()}>
{T("サイズチャート")}
{kind === 'fiber' ? : }
);
}
function Drawer({ title, onClose, footer, children, back = T("コンポーネント") }) {
React.useEffect(() => {const f = (e) => e.key === 'Escape' && onClose();addEventListener('keydown', f);return () => removeEventListener('keydown', f);}, []);
return (
);
}
/* 左右対称スイッチ(ストロボ・ライト・アーム共通) */
function SymSwitch({ st, set }) {
return set(E.setSym(st, v))}>{T("左右対称")}{T("ONのときは右と左に同じものを付けます")}
;
}
Object.assign(window, { uwTrack, uwTrackCounts, fmt, yen, gTxt, useCountUp, Num, Step, Toggle, Chips, PopCtx, PName, ProductPop, Card, SizeChart, Drawer, SymSwitch });