// Reusable UI primitives for 株価急騰要因メモ const { useState, useEffect, useRef, useMemo } = React; // Tiny sparkline function Sparkline({ points, color = "var(--up)", w = 56, h = 18 }) { const min = Math.min(...points); const max = Math.max(...points); const range = max - min || 1; const stepX = w / (points.length - 1); const path = points .map((p, i) => `${i === 0 ? "M" : "L"} ${(i * stepX).toFixed(2)} ${(h - ((p - min) / range) * h).toFixed(2)}`) .join(" "); return ( ); } // Up/down chip (Japanese convention: red=up, green=down) function PctChip({ pct, size = "md" }) { const isUp = pct >= 0; const sz = size === "sm" ? { fs: 11.5, py: "2px", px: "5px" } : size === "lg" ? { fs: 16, py: "4px", px: "8px" } : { fs: 13, py: "3px", px: "6px" }; return ( {isUp ? "+" : ""}{pct.toFixed(1)}% ); } // Section header used inside scrolling pages function SectionHeader({ kicker, title, more, onMore }) { return (
{kicker &&
{kicker}
}

{title}

{more && ( )}
); } // Zone break — ホームの大きな意味の切れ目(今日 / 蓄積 / ご案内)を示す区切り function ZoneBreak({ label, sub }) { return (
{label}
{sub && (
{sub}
)}
); } // Top app bar function TopBar({ onMenu, onHome, status = "fallback" }) { return (
); } // Live connection status pill — replaces the old static BETA badge function ConnectionPill({ status }) { if (status === "live") { return ( LIVE ); } if (status === "connecting") { return ( 接続中… ); } if (status === "error") { return ( オフライン ); } // fallback (未設定・サンプル表示) return ( サンプル ); } const iconBtnStyle = { background: "transparent", border: "none", color: "inherit", padding: 4, cursor: "pointer", display: "inline-flex", }; // Live ticker strip (under top bar) — 最新の検知を新しい順に流す function Ticker({ stocks }) { // 同一銘柄は最新の1件のみ、最大12件 const seen = new Set(); const latest = []; (stocks || []).forEach(s => { const k = String(s.code); if (seen.has(k) || latest.length >= 12) return; seen.add(k); latest.push(s); }); if (latest.length === 0) return null; const items = [...latest, ...latest, ...latest]; return (
LIVE
{items.map((s, i) => { // 上昇率: pct があればそれ、無ければ急騰時→現在価格から算出 let pct = (s.pct !== null && s.pct !== undefined) ? Number(s.pct) : null; if (pct === null || isNaN(pct)) { const sp = Number(s.surge_price ?? s.price); const cp = s.current_price != null ? Number(s.current_price) : NaN; pct = (sp > 0 && !isNaN(cp)) ? ((cp - sp) / sp) * 100 : null; } const theme = (s.themes && s.themes[0]) || null; return ( {s.code} {s.name} {pct !== null ? ( = 0 ? "#ff8a8f" : "#7fd29b", fontWeight: 600, }}> {pct >= 0 ? "+" : ""}{pct.toFixed(1)}% ) : theme ? ( {theme} ) : null} · ); })}
); } // Hero function Hero({ onPrimary, onSecondary, stockCount = 0, status = "fallback", lastUpdate = null }) { const updateLabel = lastUpdate ? new Date(lastUpdate).toLocaleTimeString("ja-JP", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "Asia/Tokyo" }) : "—"; return (
{/* Subtle background grid */} ); } // Stock card — list item style function StockCard({ stock, onOpen, idx, isNew = false, highlightThemes = null, onTheme = null }) { const hasPrice = stock.pct !== null && stock.pct !== undefined; const hasSpark = Array.isArray(stock.spark) && stock.spark.length > 0; return (
onOpen?.(stock)} style={{ background: "var(--surface)", padding: "14px 16px", borderBottom: "1px solid var(--hairline)", cursor: "pointer", display: "flex", gap: 12, alignItems: "flex-start", }}>
{String(idx + 1).padStart(2, "0")}
{stock.code} {stock.market} {stock.time}
{stock.name}
{hasPrice ? ( <> {hasSpark && = 0 ? "var(--up)" : "var(--down)"}/>} ¥{stock.price.toLocaleString()} {stock.vol && 出来高 {stock.vol} } ) : ( <> 急騰検知 {stock.chart_url && ( )} チャート付き )}
急騰理由 {stock.reason}
{stock.themes.map(t => { const hit = highlightThemes && highlightThemes.has(String(t).trim()); return ( { e.stopPropagation(); onTheme(String(t).trim()); }) : undefined} role={onTheme ? "button" : undefined} style={{ fontSize: 10.5, color: hit ? "#fff" : "var(--ink-soft)", background: hit ? "var(--navy)" : "transparent", border: "1px solid " + (hit ? "var(--navy)" : "var(--hairline-strong)"), fontWeight: hit ? 600 : 400, padding: "2px 7px", cursor: onTheme ? "pointer" : undefined, }}>{t} ); })}
); } // Bottom tab bar function TabBar({ active, onChange }) { const tabs = [ { k: "home", label: "ホーム", icon: (a) => }, { k: "cats", label: "カテゴリ", icon: (a) => }, { k: "archive",label: "アーカイブ", icon: (a) => }, { k: "watch", label: "ウォッチ", icon: (a) => }, { k: "report", label: "レポート", icon: (a) => }, { k: "plan", label: "プラン", icon: (a) => }, ]; return (
{tabs.map(t => { const a = active === t.k; return ( ); })}
); } function IconHome({ active }) { return ; } function IconGrid({ active }) { return ; } function IconChart({ active }) { return ; } function IconStar({ active }) { return ; } function IconCrown({ active }) { return ; } function IconArchive({ active }) { return ; } Object.assign(window, { Sparkline, PctChip, SectionHeader, TopBar, Ticker, Hero, StockCard, TabBar, ConnectionPill, ZoneBreak, IconHome, IconGrid, IconArchive, IconStar, IconChart, IconCrown, });