// RegionsView(地区洞察):概要条 + 世界地图(国家粒度)+ 区域卡片网格 + 区域→国家下钻表。 // 数据契约见 SocialBiApp.jsx 的 useSocialBi();金额为 USD 分、比例为 0-1,渲染时统一走 metrics/format 层。 import { Fragment, useMemo, useState } from "react"; import { VisualMapComponent } from "echarts/components"; import * as echarts from "echarts/core"; import { EChart } from "../../charts/EChart.jsx"; import { resolveCountryMeta } from "../../data/countryMeta.js"; import worldMap from "../../data/world.json"; import { formatCount, formatMoneyMinor, formatRatioPercent, isBlank } from "../format.js"; import { aggregateMetricMaps, appColor, formatMetric, metricChartValue, metricLabel, metricTooltip } from "../metrics.js"; import { useSocialBi } from "../SocialBiApp.jsx"; import "./regions-view.css"; // EChart.jsx 未注册 visualMap 组件,这里按需补注册(echarts.use 幂等,不影响其它图表)。 echarts.use([VisualMapComponent]); const MAP_METRICS = [ { key: "recharge_usd_minor", label: "充值" }, { key: "new_users", label: "新增" }, { key: "active_users", label: "日活" } ]; const CARD_METRICS = ["new_users", "active_users", "arppu_usd_minor"]; const TABLE_METRICS = [ "recharge_usd_minor", "new_users", "active_users", "paid_users", "arppu_usd_minor", "d1_retention_rate", "gift_coin_spent" ]; // databi-world 的合法地名索引(小写 → 原始 feature name),用于把 ISO alpha-2 / 后端国家名归一成地图地名。 const MAP_NAME_INDEX = new Map(); (worldMap.features || []).forEach((feature) => { const name = feature?.properties?.name; if (name) { MAP_NAME_INDEX.set(String(name).trim().toLowerCase(), name); } }); // 复用 countryMeta 的 ISO alpha-2 → 备选英文/中文名列表,再校验哪一个是 databi-world 的 feature name。 function resolveMapName(countryCode, countryName) { const meta = resolveCountryMeta({ code: countryCode, name: countryName }); const candidates = [...(meta?.names || []), countryName]; for (const candidate of candidates) { const hit = MAP_NAME_INDEX.get(String(candidate || "").trim().toLowerCase()); if (hit) { return hit; } } return ""; } function countryDisplayName(countryCode, countryName) { const meta = resolveCountryMeta({ code: countryCode, name: countryName }); return meta?.label || countryName || countryCode || "未知国家"; } function rechargeValue(row) { return isBlank(row?.recharge_usd_minor) ? -1 : Number(row.recharge_usd_minor); } function byRechargeDesc(a, b) { return rechargeValue(b) - rechargeValue(a); } export function RegionsView() { const { appCodes, derived, isLoading } = useSocialBi(); const { appTotals, countryTotals, regionTotals } = derived; const [mapMetric, setMapMetric] = useState("recharge_usd_minor"); const [expandedRegions, setExpandedRegions] = useState(() => new Set()); const regionRows = useMemo(() => [...regionTotals].sort(byRechargeDesc), [regionTotals]); const regionSummary = useMemo(() => aggregateMetricMaps(regionTotals), [regionTotals]); const regionCount = useMemo( () => new Set(regionTotals.map((row) => `${row.app_code}:${row.region_id}`)).size, [regionTotals] ); const appCount = useMemo(() => new Set(regionTotals.map((row) => row.app_code)).size, [regionTotals]); const countryCount = useMemo(() => { const codes = new Set(); countryTotals.forEach((row) => { const key = String(row.country_code || row.country_name || "").trim(); if (key) { codes.add(key); } }); return codes.size; }, [countryTotals]); // 每个 App 的总充值(占比条分母):优先 appTotals 的整 App 口径,缺失时退化为可见区域行求和。 const appRechargeTotals = useMemo(() => { const totals = new Map(); appTotals.forEach((row) => { if (!isBlank(row.recharge_usd_minor)) { totals.set(row.app_code, Number(row.recharge_usd_minor)); } }); const fallback = new Map(); regionTotals.forEach((row) => { if (!isBlank(row.recharge_usd_minor)) { fallback.set(row.app_code, (fallback.get(row.app_code) || 0) + Number(row.recharge_usd_minor)); } }); fallback.forEach((value, code) => { if (!totals.has(code)) { totals.set(code, value); } }); return totals; }, [appTotals, regionTotals]); // 下钻子行:countryTotals 按 app_code + region_id 分组(region_id 缺失已在数据层归一成 0,对应"未划分区域")。 const countriesByRegion = useMemo(() => { const groups = new Map(); countryTotals.forEach((row) => { const key = `${row.app_code}:${Number(row.region_id ?? 0)}`; const bucket = groups.get(key) || []; bucket.push(row); groups.set(key, bucket); }); groups.forEach((rows) => rows.sort(byRechargeDesc)); return groups; }, [countryTotals]); // 地图数据:跨 App 同国家先按地图地名合桶,再用 aggregateMetricMaps 合并(比例按分子分母重算)。 const mapIndex = useMemo(() => { const buckets = new Map(); const unmatched = new Set(); countryTotals.forEach((row) => { const mapName = resolveMapName(row.country_code, row.country_name); if (!mapName) { unmatched.add(String(row.country_code || row.country_name || "?")); return; } const bucket = buckets.get(mapName) || { label: countryDisplayName(row.country_code, row.country_name), rows: [] }; bucket.rows.push(row); buckets.set(mapName, bucket); }); const entries = new Map(); buckets.forEach((bucket, mapName) => { entries.set(mapName, { label: bucket.label, metrics: aggregateMetricMaps(bucket.rows) }); }); return { entries, unmatchedCount: unmatched.size }; }, [countryTotals]); const mapOption = useMemo(() => { const data = []; mapIndex.entries.forEach((entry, mapName) => { const value = metricChartValue(mapMetric, entry.metrics[mapMetric]); if (value !== null) { data.push({ name: mapName, value }); } }); const max = data.reduce((acc, item) => Math.max(acc, item.value), 0) || 1; return { series: [ { data, emphasis: { itemStyle: { areaColor: "#93b6f2" }, label: { show: false } }, itemStyle: { areaColor: "#e9eef5", borderColor: "#d5dfeb", borderWidth: 0.6 }, label: { show: false }, map: "databi-world", name: metricLabel(mapMetric), roam: false, select: { disabled: true }, type: "map" } ], tooltip: { backgroundColor: "#ffffff", borderColor: "#e3eaf3", extraCssText: "box-shadow: 0 4px 16px rgba(16, 34, 56, 0.12);", formatter: (params) => { const entry = mapIndex.entries.get(params.name); if (!entry) { return `${params.name}
暂无数据`; } return `${entry.label}
${metricLabel(mapMetric)}:${formatMetric(mapMetric, entry.metrics[mapMetric])}`; }, textStyle: { color: "#14263c", fontSize: 12 } }, visualMap: { bottom: 10, calculable: false, inRange: { color: ["#dbeafe", "#2557d6"] }, itemHeight: 110, itemWidth: 10, left: 12, max, min: 0, orient: "horizontal", text: ["高", "低"], textStyle: { color: "#5b7089", fontSize: 11 } } }; }, [mapIndex, mapMetric]); const toggleRegion = (key) => { setExpandedRegions((current) => { const next = new Set(current); if (next.has(key)) { next.delete(key); } else { next.add(key); } return next; }); }; if (isLoading && !regionTotals.length && !countryTotals.length) { return ; } if (!regionTotals.length) { return (
暂无区域数据 检查顶栏区域筛选,或该 App 尚未配置区域目录(legacy App 在其 Mongo sys_region_config,hyapp 在区域管理)
); } return ( <>
覆盖区域数 {formatCount(regionCount)} 按 App × 区域去重 · 覆盖 {formatCount(appCount)} 个 App
覆盖国家数 {formatCount(countryCount)} countryTotals 按国家去重(跨 App 合并)
区域充值合计 {formatMoneyMinor(regionSummary.recharge_usd_minor)} 当前区间 · 可见区域行合计
全球分布 {countryCount ? `${formatCount(countryCount)} 个国家 · 跨 App 合并` : "国家粒度"} {mapIndex.unmatchedCount ? ` · ${mapIndex.unmatchedCount} 个国家未匹配到地图` : ""}
{MAP_METRICS.map((item) => ( ))}
{mapIndex.entries.size ? ( ) : (
暂无国家数据 当前区间没有国家粒度数据,或国家编码未能匹配到世界地图
)}
{regionRows.map((row) => { const key = `${row.app_code}:${row.region_id}`; const appTotal = appRechargeTotals.get(row.app_code); const share = appTotal && !isBlank(row.recharge_usd_minor) ? Number(row.recharge_usd_minor) / appTotal : null; return (
{row.region_name} {row.restricted ? ( 范围裁剪 ) : null} {row.app_name}
{metricLabel("recharge_usd_minor")} {formatMoneyMinor(row.recharge_usd_minor)}
{CARD_METRICS.map((metric) => (
{metricLabel(metric)} {formatMetric(metric, row[metric])}
))}
占 {row.app_name} 总充值 {share === null ? "--" : formatRatioPercent(share)}
); })}
区域下钻 点击区域行展开国家明细 · 按充值降序
{TABLE_METRICS.map((metric) => ( ))} {TABLE_METRICS.map((metric) => ( ))} {regionRows.map((row) => { const key = `${row.app_code}:${row.region_id}`; const isOpen = expandedRegions.has(key); const children = isOpen ? countriesByRegion.get(key) || [] : []; return ( toggleRegion(key)}> {TABLE_METRICS.map((metric) => ( ))} {isOpen && !children.length ? ( ) : null} {children.map((child) => ( {TABLE_METRICS.map((metric) => ( ))} ))} ); })}
地区 / 国家 App {metricLabel(metric)}
全部区域 {formatCount(appCount)} 个 App{formatMetric(metric, regionSummary[metric])}
{row.region_name} {row.restricted ? ( 范围裁剪 ) : null} {row.app_name} {formatMetric(metric, row[metric])}
该区域暂无国家明细
{countryDisplayName(child.country_code, child.country_name)} {child.app_name}{formatMetric(metric, child[metric])}
); } function RegionsSkeleton() { return ( <>
{[0, 1, 2].map((index) => (
))}
{[0, 1, 2].map((index) => (
))}
); }