// data.jsx — shared context, design atoms, icons, mock data
const { createContext, useContext, useState, useRef, useEffect } = React;
/* ─────────────────────────── App context ─────────────────────────── */
const AppCtx = createContext(null);
const useApp = () => useContext(AppCtx);
/* ─────────────────────────── Simple line icons ───────────────────────────
Minimal geometric strokes for UI chrome only. */
function Icon({ name, size = 24, stroke = 'currentColor', sw = 1.8, fill = 'none', style }) {
const p = { fill, stroke, strokeWidth: sw, strokeLinecap: 'round', strokeLinejoin: 'round' };
const paths = {
home: <> >,
map: <> >,
heart: ,
gear: <> >,
plus: ,
chevR: ,
chevL: ,
chevD: ,
back: ,
lock: <> >,
camera: <> >,
note: <> >,
link: <> >,
mic: <> >,
send: ,
battery:<> >,
car: <> >,
train: <> >,
sun: <> >,
rain: <> >,
check: ,
sparkle: ,
copy: <> >,
share: <> >,
pin: <> >,
bag: <> >,
google: ,
arrow: ,
skip: ,
refresh:<> >,
edit: ,
clock: <> >,
moon: ,
walk: <> >,
cal: <> >,
chat: <> >,
route: <> >,
expand: ,
locate: <> >,
};
return (
{paths[name]}
);
}
/* ─────────────────────────── Buttons & atoms ─────────────────────────── */
function Btn({ children, onClick, variant = 'primary', full, size = 'md', style, icon, disabled }) {
const base = {
fontFamily: 'var(--font-ui)', fontWeight: 700, cursor: disabled ? 'default' : 'pointer',
border: 'none', borderRadius: 999, display: 'inline-flex', alignItems: 'center',
justifyContent: 'center', gap: 8, width: full ? '100%' : 'auto',
transition: 'transform .15s ease, box-shadow .2s ease, background .2s ease',
letterSpacing: '.02em', whiteSpace: 'nowrap', opacity: disabled ? 0.5 : 1,
};
const sizes = {
sm: { padding: '9px 16px', fontSize: 14 },
md: { padding: '14px 22px', fontSize: 16 },
lg: { padding: '17px 26px', fontSize: 17 },
};
const variants = {
primary: { background: 'var(--ink)', color: 'var(--paper)', boxShadow: '0 8px 20px -8px rgba(61,54,44,.55)' },
rose: { background: 'var(--me)', color: '#fff', boxShadow: '0 10px 22px -10px var(--me)' },
amethyst:{ background: 'var(--amethyst)', color: '#fff', boxShadow: '0 10px 22px -10px var(--amethyst)' },
soft: { background: 'var(--surface)', color: 'var(--ink)', boxShadow: 'inset 0 0 0 1px var(--line-2)' },
ghost: { background: 'transparent', color: 'var(--ink-soft)' },
};
const ref = useRef(null);
return (
!disabled && (e.currentTarget.style.transform = 'scale(.97)')}
onPointerUp={e => (e.currentTarget.style.transform = '')}
onPointerLeave={e => (e.currentTarget.style.transform = '')}
style={{ ...base, ...sizes[size], ...variants[variant], ...style }}>
{icon && }
{children}
);
}
function Chip({ children, active, onClick, color = 'var(--ink)', style }) {
return (
{children}
);
}
/* card surface */
function Card({ children, style, onClick, pad = 18 }) {
return (
{children}
);
}
/* small charm avatar (two interlocking rings = the amulet) */
function Charm({ size = 30, a = 'var(--me)', b = 'var(--partner)', linked = true }) {
return (
);
}
/* AI(相棒の犬)のチャットアイコン。丸く切り抜いて表示する */
function AiAvatar({ size = 28, style }) {
return (
);
}
/* avatar disc with initial */
function Avatar({ name = 'A', color = 'var(--me)', size = 38 }) {
return (
{name}
);
}
/* striped image placeholder */
// 何も画像が無いときに出す共通フォールバック画像(静的サイト直下にホスト・差し替え可)
const FALLBACK_IMG = '/fallback.webp';
function ImgSlot({ label = 'IMAGE', h = 120, r = 16, style, src }) {
const [srcFailed, setSrcFailed] = React.useState(false);
const [fbFailed, setFbFailed] = React.useState(false);
// ① photoUrl(src)→ ② 失敗/未指定ならフォールバック画像 → ③ それも失敗したら色枠ラベル
const useSrc = src && !srcFailed;
if (useSrc || !fbFailed) {
return (
(useSrc ? setSrcFailed(true) : setFbFailed(true))} style={{
height: h, width: '100%', borderRadius: r, objectFit: 'cover', display: 'block',
border: '1px solid var(--line)', ...style,
}} />
);
}
return (
{label}
);
}
/* section eyebrow */
function Eyebrow({ children, color = 'var(--ink-faint)' }) {
return {children}
;
}
/* ── Firestore イベント → 画面用の形に正規化(live: true が実データの印) ── */
function normalizeEvent(d, uid) {
const stats = d.inputStats || {};
const mine = (uid && stats[uid] && stats[uid].messageCount) || 0;
const theirs = Object.entries(stats)
.filter(([k]) => k !== uid)
.reduce((a, [, v]) => a + ((v && v.messageCount) || 0), 0);
return {
id: d.id, live: true,
date: d.dateLabel || d.date || '', dow: d.dow || '',
title: d.title || 'ただの週末', area: d.area || '',
progress: Math.min(1, (mine + theirs) / 16), mine, theirs,
weather: 'sun', planStatus: d.planStatus || 'none', raw: d,
};
}
/* ─────────────────────────── Shells ─────────────────────────── */
const SAFE_TOP = 50;
const NAV_H = 86; // nav incl. home-indicator pad
function TopBar({ title, onBack, right, sub, center }) {
return (
{onBack ? (
) :
}
{title &&
{title}
}
{sub &&
{sub}
}
{right || (onBack ?
: null)}
);
}
function BottomNav({ active, go }) {
const items = [
{ id: 'home', icon: 'home', label: 'ホーム' },
{ id: 'amulet-map', icon: 'map', label: 'マップ' },
{ id: 'wish-list', icon: 'heart', label: '小言' },
{ id: 'settings', icon: 'gear', label: '設定' },
];
return (
{items.map(it => {
const on = active === it.id;
return (
go(it.id)} style={{
background: 'none', border: 'none', cursor: 'pointer',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4,
color: on ? 'var(--amethyst)' : 'var(--ink-faint)', padding: '4px 14px',
transition: 'color .2s',
}}>
{it.label}
);
})}
);
}
/* scroll body that respects safe areas */
function Body({ children, nav, pad = 18, style, scrollRef }) {
return (
{children}
);
}
function Field({ label, value = '', onChange, placeholder, type = 'text', icon, area, rows = 3, right }) {
const handle = (e) => onChange && onChange(e.target.value);
return (
{label && {label}
}
{icon && }
{area ? (
) : (
)}
{right}
);
}
function Segmented({ options, value, onChange, color = 'var(--amethyst)' }) {
return (
{options.map(o => {
const val = typeof o === 'string' ? o : o.value;
const lab = typeof o === 'string' ? o : o.label;
const on = value === val;
return (
onChange(val)} style={{
flex: 1, padding: '9px 6px', borderRadius: 9, border: 'none', cursor: 'pointer',
fontFamily: 'var(--font-ui)', fontWeight: on ? 700 : 500, fontSize: 13.5,
background: on ? 'var(--surface)' : 'transparent',
color: on ? color : 'var(--ink-faint)',
boxShadow: on ? '0 2px 8px -4px rgba(61,54,44,.4)' : 'none',
transition: 'all .18s',
}}>{lab}
);
})}
);
}
function PrivacyBadge({ children = '相手には見えません' }) {
return (
{children}
);
}
/* ─────────────────────────── Google Maps ─────────────────────────── */
const MAP_STYLE = [
{ elementType: 'labels.icon', stylers: [{ visibility: 'off' }] },
{ featureType: 'poi', stylers: [{ visibility: 'off' }] },
{ featureType: 'transit.station', stylers: [{ visibility: 'simplified' }] },
{ featureType: 'road', elementType: 'labels.icon', stylers: [{ visibility: 'off' }] },
{ elementType: 'geometry', stylers: [{ saturation: -20 }, { lightness: 5 }] },
];
// GoogleMapView — ref-based Google Map with custom SVG markers
// Props: center {lat,lng}, zoom, interactive, markers [{id,pos,label,color,onClick}],
// onMapClick(latLng), onReady(map), style
function GoogleMapView({ center, zoom = 15, interactive = true, markers = [], onMapClick, onReady, fitSignature, style = {} }) {
const elRef = React.useRef(null);
const mapRef = React.useRef(null);
const markerLayerRef = React.useRef([]);
const [ready, setReady] = React.useState(false);
React.useEffect(() => {
let cancelled = false;
window.__mapsReady.then((g) => {
if (cancelled || !elRef.current) return;
const map = new g.maps.Map(elRef.current, {
center, zoom,
disableDefaultUI: true,
gestureHandling: interactive ? 'greedy' : 'none',
clickableIcons: false,
keyboardShortcuts: false,
styles: MAP_STYLE,
backgroundColor: '#F0EBE1',
});
mapRef.current = map;
if (onMapClick) map.addListener('click', (e) => onMapClick(e.latLng));
if (onReady) onReady(map);
setReady(true);
});
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
React.useEffect(() => {
if (!ready || !mapRef.current) return;
const g = window.google;
markerLayerRef.current.forEach(m => m.setMap(null));
markerLayerRef.current = markers.map((mk) => {
const fill = mk.color || '#8E7DA6';
const svg = `data:image/svg+xml;utf8,${encodeURIComponent(
`
`
)}`;
const marker = new g.maps.Marker({
position: mk.pos,
map: mapRef.current,
icon: { url: svg, scaledSize: new g.maps.Size(32, 40), anchor: new g.maps.Point(16, 38) },
title: mk.name || mk.title || '',
zIndex: mk.active ? 100 : 10,
});
if (mk.onClick) marker.addListener('click', mk.onClick);
return marker;
});
}, [markers, ready]);
React.useEffect(() => {
if (!ready || !mapRef.current || !center || interactive) return;
mapRef.current.setCenter(center);
}, [center, interactive, ready]);
// fitSignature が変わったら、全マーカーが収まる倍率・中心へ自動フィット。
// signature は座標のみから作るため、ピン選択(activeの切替)では再フィットしない。
React.useEffect(() => {
if (!ready || !mapRef.current || !fitSignature) return;
const g = window.google;
const positions = markers.map(m => m.pos).filter(Boolean);
if (positions.length === 0) return;
if (positions.length === 1) {
mapRef.current.setCenter(positions[0]);
mapRef.current.setZoom(zoom);
return;
}
const b = new g.maps.LatLngBounds();
positions.forEach(p => b.extend(p));
mapRef.current.fitBounds(b, { top: 64, right: 56, bottom: 64, left: 56 });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fitSignature, ready]);
return (
);
}
/* ─────────────────────────── Weather (Open-Meteo) ───────────────────────────
予定の日付・エリアから実際の天気を取得し 'sun' | 'rain' を返す。
APIキー不要・CORS対応の Open-Meteo を使用。エリアはジオコーディングで
座標化(失敗時は東京にフォールバック)。結果はキャッシュして再取得を防ぐ。 */
const WX_TOKYO = { lat: 35.6812, lon: 139.7671 };
const _geoCache = new Map();
const _wxCache = new Map();
// WMO weather code: 51+ は霧雨/雨/雪/雷雨とみなし 'rain'、それ以外は 'sun'
const wxKind = (code) => (typeof code === 'number' && code >= 51 ? 'rain' : 'sun');
async function geocodeArea(area) {
const q = (area || '').replace(/あたり|周辺|近辺/g, '').trim();
if (!q) return WX_TOKYO;
if (_geoCache.has(q)) return _geoCache.get(q);
const pr = (async () => {
try {
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(q)}&count=1&language=ja&countryCode=JP`;
const j = await (await fetch(url)).json();
const g = j && j.results && j.results[0];
if (g && typeof g.latitude === 'number') return { lat: g.latitude, lon: g.longitude };
} catch (_) {}
return WX_TOKYO;
})();
_geoCache.set(q, pr);
return pr;
}
async function getWeather(dateISO, area) {
const iso = (dateISO || '').slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return 'sun';
const key = `${iso}|${area || ''}`;
if (_wxCache.has(key)) return _wxCache.get(key);
const pr = (async () => {
try {
const { lat, lon } = await geocodeArea(area);
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&daily=weather_code&timezone=Asia%2FTokyo&start_date=${iso}&end_date=${iso}`;
const j = await (await fetch(url)).json();
const code = j && j.daily && j.daily.weather_code && j.daily.weather_code[0];
if (code == null) return 'sun';
return wxKind(code);
} catch (_) {
return 'sun';
}
})();
_wxCache.set(key, pr);
return pr;
}
/* ─────────── 地図のスムーズズーム ───────────
ラスタ地図の setZoom() は即座に切り替わってアニメーションしない。
ただし±1段のズームは短いクロスフェードが入るため、1段ずつ刻んで
連続実行すると「寄っていく/引いていく」連続ズームに見える。
panTo は近距離ならネイティブでアニメーションするので、先にパンしてからズームを刻む。 */
function mapFlyTo(map, pos, targetZoom) {
if (!map) return;
if (map.__flyTimer) clearTimeout(map.__flyTimer);
const target = Math.round(targetZoom);
const stepZoom = () => {
const z = Math.round(map.getZoom());
if (z === target) return;
map.setZoom(z < target ? z + 1 : z - 1);
map.__flyTimer = setTimeout(stepZoom, 140); // 1段ごとのクロスフェードを待つ
};
if (pos) map.panTo(pos);
map.__flyTimer = setTimeout(stepZoom, 200); // パンのアニメーションに少し譲る
}
// bounds が収まるズーム段数を(fitBounds を使わずに)計算する。mapFlyTo と組み合わせて
// 「全体表示」もアニメーションさせるため。pad はビューポート両端の余白(px)。
function zoomForBounds(map, bounds, pad = 110) {
const div = map.getDiv();
const w = Math.max(1, div.clientWidth - pad), h = Math.max(1, div.clientHeight - pad);
const ne = bounds.getNorthEast(), sw = bounds.getSouthWest();
const latFrac = lat => {
const s = Math.sin(lat * Math.PI / 180);
return Math.log((1 + s) / (1 - s)) / 2;
};
const latFraction = Math.abs(latFrac(ne.lat()) - latFrac(sw.lat())) / (2 * Math.PI);
let lngDiff = ne.lng() - sw.lng();
if (lngDiff < 0) lngDiff += 360;
const lngFraction = lngDiff / 360;
const zLat = latFraction > 0 ? Math.log2(h / 256 / latFraction) : Infinity;
const zLng = lngFraction > 0 ? Math.log2(w / 256 / lngFraction) : Infinity;
const z = Math.floor(Math.min(zLat, zLng));
return Number.isFinite(z) ? Math.max(3, Math.min(18, z)) : 15;
}
function mapFlyToBounds(map, positions, pad) {
if (!map || !window.google || !positions.length) return;
const b = new window.google.maps.LatLngBounds();
positions.forEach(p => b.extend(p));
mapFlyTo(map, b.getCenter(), zoomForBounds(map, b, pad));
}
const Weather = { get: getWeather };
/* 予定日の天気アイコン(取得できるまでは fallback を表示)。
色はデフォルトで sun=gold / rain=partner。style で上書き可。 */
function WeatherIcon({ dateISO, area, size = 13, fallback = 'sun', style }) {
const [kind, setKind] = useState(fallback);
useEffect(() => {
let dead = false;
getWeather(dateISO, area).then(k => { if (!dead) setKind(k); });
return () => { dead = true; };
}, [dateISO, area]);
const color = kind === 'rain' ? 'var(--partner)' : 'var(--gold)';
return ;
}
/* プラン詳細トップのバッジ(アイコン+ラベル)。取得前は非表示。 */
function WeatherBadge({ dateISO, area }) {
const [kind, setKind] = useState(null);
useEffect(() => {
let dead = false;
getWeather(dateISO, area).then(k => { if (!dead) setKind(k); });
return () => { dead = true; };
}, [dateISO, area]);
if (!kind) return null;
const rain = kind === 'rain';
return (
{rain ? '雨' : '晴れ'}
);
}
/* ─────────── Lottie ───────────
vendor/lottie_light.min.js(svgレンダラのみ)を使う。
viewBox は "x y w h" 形式。素材のキャンバスに余白が多いとき、
描画したい範囲だけを切り出して等倍に見せるために使う。 */
function LottieBox({ src, viewBox, loop = true, speed = 1, paused = false, style }) {
const host = useRef(null);
const animRef = useRef(null);
useEffect(() => {
const lottie = window.lottie;
if (!lottie || !host.current) return;
const anim = lottie.loadAnimation({
container: host.current, renderer: 'svg', loop, autoplay: true, path: src,
rendererSettings: viewBox ? { viewBoxSize: viewBox } : undefined,
});
anim.setSpeed(speed);
animRef.current = anim;
return () => { animRef.current = null; anim.destroy(); };
}, [src, viewBox]);
/* paused: 生成に失敗したときなど「動きを止めて見せたい」場面で使う(描画は残したまま停止) */
useEffect(() => {
const anim = animRef.current;
if (!anim) return;
paused ? anim.pause() : anim.play();
}, [paused]);
return
;
}
Object.assign(window, {
AppCtx, useApp, Icon, Btn, Chip, Card, Charm, Avatar, AiAvatar, ImgSlot, Eyebrow,
TopBar, BottomNav, Body, Field, Segmented, PrivacyBadge, SAFE_TOP, NAV_H,
GoogleMapView, MAP_STYLE, normalizeEvent, LottieBox,
mapFlyTo, mapFlyToBounds,
Weather, WeatherIcon, WeatherBadge,
});