// 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 */}
日本株 · 当日の急騰背景を整理
急騰の理由から、
資金の流れを読む。
今日動いた日本株の材料・決算・テーマを整理し、
今どこに資金が集まっているのかを可視化します。
{/* Inline stat row */}
{[
{ l: "本日急騰した銘柄", v: String(stockCount), u: "件" },
{ l: "接続状態", v: status === "live" ? "LIVE" : status === "connecting" ? "接続中" : status === "error" ? "オフライン" : "サンプル", u: "" },
{ l: "最終更新", v: updateLabel, u: "JST" },
].map((s, i) => (
{s.l}
{i === 1 && status === "live" && }
{s.v}{s.u && {s.u}}
))}
);
}
// 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}
{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,
});