diff --git a/.gitignore b/.gitignore index 5e85fe6..ac9a88d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,6 @@ *.pyc .DS_Store -# V2 运行时数据(SQLite + 本地媒体) -data/ - # 反编译参考资料(约 40 个 bundle),设计结论已写入 docs/extension/plan.md §2 reference/ diff --git a/README.md b/README.md index 0fe36d4..d6f1441 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,9 @@ php -S localhost:8899 ### 2. 打开浏览器 -访问 **http://localhost:8899**,会看到 5 张示例图表和 11 张泡泡玛特财报图表: +访问 **http://localhost:8899**。页面左侧选择公司,中间用 Tab 切换分析维度,右侧可跳转到当前分类的图表。 + +当前 manifest 内登记了 5 张示例图表和 14 张泡泡玛特财报图表: **示例图表:** 1. **存货、营收与存货周转天数** — 双柱 + 折线,双 Y 轴 @@ -50,7 +52,7 @@ php -S localhost:8899 ### 3. 修改数据 -编辑 `data/*.csv`,保存后点击页面右上角的 **「重新加载数据」** 按钮。 +编辑对应公司的 CSV,保存后点击页面右上角的 **「重新加载数据」** 按钮。数据目录按公司划分,例如 `data/ppmt/ppmt_profit.csv`、`data/demo/inventory.csv`。 CSV 格式:首行是表头,逗号分隔,数字列会自动转换。示例: @@ -69,22 +71,20 @@ period,inventory,revenue,turnover_days ## 项目结构 ``` -echart-demo/ +smart-fin-chart/ ├── index.html # 主页面 -├── data/ # 数据文件 -│ ├── inventory.csv # 图 1-5:示例数据 -│ ├── margin.csv -│ ├── growth.csv -│ ├── fx.csv -│ ├── ppmt.xlsx # 泡泡玛特原始财报 -│ └── ppmt_*.csv # 图 6-16:从 xlsx 提取的各口径数据 +├── manifest.json # 公司、Tab、图表、CSV 的唯一配置来源 +├── data/ # 数据文件(按公司划分) +│ ├── demo/ +│ └── ppmt/ ├── js/ -│ ├── main.js # 入口:CHARTS 清单、渲染、导出 PNG +│ ├── main.js # 入口:读取 manifest、渲染、导出 PNG +│ ├── ui.js # 三栏 UI 构建 │ ├── csv.js # CSV 解析工具 │ ├── theme.js # 主题:配色、字体、水印、共享系列构造器 -│ └── charts/ # 一图一文件,共 16 张 +│ └── charts/ # 图表构建函数与 registry ├── test/ -│ └── regress.mjs # 回归测试:SSR 渲染 16 张图 + 样式规范断言 +│ └── regress.mjs # 回归测试:遍历 manifest + 样式规范断言 └── css/ └── style.css # 页面样式 ``` @@ -95,13 +95,13 @@ echart-demo/ npm test ``` -用 ECharts 的 SSR 模式把 16 张图渲染成 SVG,断言: +用 ECharts 的 SSR 模式遍历 manifest 中的图表并渲染成 SVG,断言: - `legend.top` = 85、`grid.top` = 145(统一间距) - 折线 `symbolSize` = 9、`borderWidth` = 2.5(空心圆点规范) - SVG 属性未被字体名里的引号提前闭合 -加新图后往 `regress.mjs` 顶部的 `all` 数组加一行,否则不会被测到。 +回归测试会遍历 `manifest.json`,不用再手工维护图表清单。 --- @@ -138,9 +138,9 @@ A:点击页面右上角的 **「重新加载数据」** 按钮。普通刷新 A: 1. 在 `data/` 下创建新 CSV 2. 在 `js/charts/` 下创建新 `.js`,从 `theme.js` 取共享配置(`lineSeries()`、`barSeries()`、`standardLegend()`、`periodAxis`),不要手写等价值 -3. 在 `js/main.js` 的 `CHARTS` 数组里加一行 -4. 在 `index.html` 加一个 `
` 容器 -5. 在 `test/regress.mjs` 的 `all` 数组里加一行,然后跑 `npm test` +3. 在 `js/charts/index.js` 注册 chart key +4. 在 `manifest.json` 对应公司与 Tab 中添加图表配置 +5. 跑 `node test/regress.mjs `;浏览器检查标签、颜色和脚注 **Q:能改水印文字吗?** A:编辑 `js/theme.js` 的 `WATERMARK_TEXT` 常量。 diff --git a/css/style.css b/css/style.css index b2785ca..c4b64df 100644 --- a/css/style.css +++ b/css/style.css @@ -1,101 +1,219 @@ :root { --ink: #333; - --grey: #8c8c8c; + --muted: #8c8c8c; --line: #e8e8e8; --bg: #f5f4f1; + --surface: #fff; --accent: #6b4a2b; + --accent-soft: #f0e9e1; } * { box-sizing: border-box; } body { margin: 0; - padding: 32px 24px 64px; background: var(--bg); color: var(--ink); font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; } -header { - max-width: 1180px; - margin: 0 auto 28px; - display: flex; - align-items: baseline; - gap: 16px; - flex-wrap: wrap; +button { + font: inherit; + font-size: 13px; + color: var(--ink); + cursor: pointer; } -header h1 { - margin: 0; - font-size: 22px; +.app-header { + min-height: 76px; + padding: 16px 28px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + background: var(--surface); + border-bottom: 1px solid var(--line); +} + +.app-brand h1, +.app-brand p, +.card-title, +.sidebar-title { margin: 0; } + +.app-brand h1 { font-size: 21px; font-weight: 600; } +.app-brand p { margin-top: 4px; color: var(--muted); font-size: 13px; } + +.reload-button, +.export-button { + padding: 7px 13px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface); + transition: border-color .15s, color .15s, background .15s; +} + +.reload-button { + color: #fff; + background: var(--accent); + border-color: var(--accent); +} + +.reload-button:hover { opacity: .88; } +.export-button:hover { color: var(--accent); border-color: var(--accent); } + +.app-layout { + display: grid; + grid-template-columns: 184px minmax(0, 1fr) 184px; + gap: 24px; + max-width: 1600px; + margin: 0 auto; + padding: 28px 24px 64px; +} + +.sidebar { + align-self: start; + position: sticky; + top: 20px; + padding: 14px 10px; + background: rgba(255, 255, 255, .58); + border: 1px solid rgba(232, 232, 232, .9); + border-radius: 10px; +} + +.sidebar-title { + padding: 0 8px 10px; + color: var(--muted); + font-size: 12px; font-weight: 600; } -header p { - margin: 0; - color: var(--grey); - font-size: 14px; -} +.company-list, +.anchor-list { display: grid; gap: 3px; } -button { - font-family: inherit; - font-size: 13px; - padding: 6px 14px; - border: 1px solid var(--line); +.company-button, +.anchor-button, +.tab-button, +.view-button { + border: 0; border-radius: 6px; - background: #fff; - color: var(--ink); - cursor: pointer; - transition: border-color .15s, color .15s; + background: transparent; + text-align: left; + transition: background .15s, color .15s; } -button:hover { - border-color: var(--accent); +.company-button { + width: 100%; + padding: 8px; +} + +.company-button:hover, +.anchor-button:hover, +.tab-button:hover { color: var(--accent); background: var(--accent-soft); } + +.company-button[data-active="true"], +.tab-button[data-active="true"] { color: var(--accent); + font-weight: 600; + background: var(--accent-soft); } -#btn-reload { - margin-left: auto; - background: var(--accent); - color: #fff; - border-color: var(--accent); +.anchor-button { + padding: 6px 8px; + color: #5f5f5f; + font-size: 12px; + line-height: 1.45; } -#btn-reload:hover { - opacity: .88; - color: #fff; +.content { min-width: 0; } + +.tabs { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 18px; + padding: 5px; + background: rgba(255, 255, 255, .6); + border: 1px solid var(--line); + border-radius: 9px; } +.tab-button { padding: 7px 11px; } + +.cards { display: grid; gap: 28px; } + .card { - max-width: 1180px; - margin: 0 auto 28px; - background: #fff; + overflow: hidden; + background: var(--surface); border-radius: 10px; box-shadow: 0 1px 3px rgba(0, 0, 0, .06); - overflow: hidden; + scroll-margin-top: 20px; } .card-bar { display: flex; align-items: center; gap: 12px; - padding: 10px 16px; + min-height: 52px; + padding: 9px 16px; border-bottom: 1px solid var(--line); } -.card-bar .src { - font-size: 12px; - color: var(--grey); +.card-heading { min-width: 0; margin-right: auto; } +.card-title { font-size: 14px; font-weight: 600; } + +.src { + display: block; + overflow: hidden; + margin-top: 2px; + color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; } -.card-bar button { margin-left: auto; } +.view-switcher { + display: flex; + padding: 2px; + background: #f4f2ee; + border-radius: 6px; +} + +.view-button { padding: 4px 7px; color: var(--muted); font-size: 12px; } +.view-button[data-active="true"] { color: var(--accent); background: var(--surface); box-shadow: 0 1px 2px rgba(0, 0, 0, .08); } .chart { width: 100%; } -.chart-error { - padding: 40px; - color: #c0392b; +.chart-error, +.empty-state, +.sidebar-empty { + margin: 0; + padding: 36px 20px; + color: #a63f32; font-size: 14px; text-align: center; } + +.sidebar-empty { padding: 8px; color: var(--muted); text-align: left; font-size: 12px; } + +@media (max-width: 1040px) { + .app-layout { grid-template-columns: 160px minmax(0, 1fr); } + .chart-sidebar { display: none; } +} + +@media (max-width: 720px) { + .app-header { padding: 14px 16px; } + .app-brand p { display: none; } + .app-layout { display: block; padding: 16px 12px 40px; } + .sidebar { position: static; margin-bottom: 12px; } + .company-sidebar { padding: 7px; } + .company-sidebar .sidebar-title { display: none; } + .company-list { display: flex; overflow-x: auto; } + .company-button { flex: 0 0 auto; width: auto; } + .tabs { overflow-x: auto; flex-wrap: nowrap; } + .tab-button { flex: 0 0 auto; } + .card-bar { align-items: flex-start; flex-wrap: wrap; } + .export-button { margin-left: auto; } + .chart { min-width: 560px; } + .card { overflow-x: auto; } +} diff --git a/index.html b/index.html index 30717e6..e6b78d6 100644 --- a/index.html +++ b/index.html @@ -8,172 +8,7 @@ - -
-

财报图表

-

改 data/*.csv 后点「重新加载」即可更新

- -
- -
-
- data/inventory.csv - -
-
-
- -
-
- data/margin.csv - -
-
-
- -
-
- data/margin.csv(毛利率 + 净利率两列) - -
-
-
- -
-
- data/growth.csv - -
-
-
- -
-
- data/fx.csv - -
-
-
- -
- -
-

📊 泡泡玛特财报分析

-

基于 data/ppmt.xlsx 自动生成

-
- -
-
- data/ppmt_assets.csv - -
-
-
- -
-
- data/ppmt_profit.csv - -
-
-
- -
-
- data/ppmt_ip.csv - -
-
-
- -
-
- data/ppmt_category.csv - -
-
-
- -
-
- data/ppmt_sankey_2026h1.csv - -
-
-
- -
-
- data/ppmt_region_channel_yoy.csv - -
-
-
- -
-
- data/ppmt_growth.csv - -
-
-
- -
-
- data/ppmt_margins.csv - -
-
-
- -
-
- data/ppmt_margin_bridge.csv - -
-
-
- -
-
- data/ppmt_core_profit.csv - -
-
-
- -
-
- data/ppmt_core_margin.csv - -
-
-
- -
-
- data/ppmt_ip_share_2026h1.csv - -
-
-
- -
-
- data/ppmt_ip_share_history.csv - -
-
-
- -
-
- data/ppmt_ip_ex_monsters.csv - -
-
-
- - +
+ diff --git a/js/charts/index.js b/js/charts/index.js new file mode 100644 index 0000000..dce8aaf --- /dev/null +++ b/js/charts/index.js @@ -0,0 +1,46 @@ +import { inventoryChart } from './inventory.js'; +import { marginChart } from './margin.js'; +import { twoLineChart } from './twoline.js'; +import { growthChart } from './growth.js'; +import { fxChart } from './fx.js'; +import { assetsChart } from './ppmt_assets.js'; +import { profitChart } from './ppmt_profit.js'; +import { ipChart } from './ppmt_ip.js'; +import { categoryChart } from './ppmt_category.js'; +import { sankeyChart } from './ppmt_sankey.js'; +import { regionChannelYoyChart } from './ppmt_region_yoy.js'; +import { ppmtGrowthChart } from './ppmt_growth.js'; +import { ppmtMarginsChart } from './ppmt_margins.js'; +import { ppmtBridgeChart } from './ppmt_bridge.js'; +import { ppmtCoreProfitChart } from './ppmt_core_profit.js'; +import { ppmtCoreMarginChart } from './ppmt_core_margin.js'; +import { ppmtIpShareChart } from './ppmt_ip_share.js'; +import { ppmtIpShareHistoryChart } from './ppmt_ip_share_history.js'; +import { ppmtIpExMonstersChart } from './ppmt_ip_ex_monsters.js'; + +/** 图表构建函数注册表。manifest 只引用这里的 key,不直接引用 JS 函数。 */ +export const chartRegistry = { + 'demo-inventory': inventoryChart, + 'demo-margin': marginChart, + 'demo-twoline': twoLineChart, + 'demo-growth': growthChart, + 'demo-fx': fxChart, + 'ppmt-assets': assetsChart, + 'ppmt-profit': profitChart, + 'ppmt-ip': ipChart, + 'ppmt-category': categoryChart, + 'ppmt-sankey': sankeyChart, + 'ppmt-region-yoy': regionChannelYoyChart, + 'ppmt-growth': ppmtGrowthChart, + 'ppmt-margins': ppmtMarginsChart, + 'ppmt-bridge': ppmtBridgeChart, + 'ppmt-core-profit': ppmtCoreProfitChart, + 'ppmt-core-margin': ppmtCoreMarginChart, + 'ppmt-ip-share': ppmtIpShareChart, + 'ppmt-ip-share-history': ppmtIpShareHistoryChart, + 'ppmt-ip-ex-monsters': ppmtIpExMonstersChart, +}; + +export function getChartBuilder(key) { + return chartRegistry[key]; +} diff --git a/js/main.js b/js/main.js index 74b5a63..2f68e4c 100644 --- a/js/main.js +++ b/js/main.js @@ -1,99 +1,192 @@ import { loadCSV } from './csv.js'; -import { inventoryChart } from './charts/inventory.js'; -import { marginChart } from './charts/margin.js'; -import { twoLineChart } from './charts/twoline.js'; -import { growthChart } from './charts/growth.js'; -import { fxChart } from './charts/fx.js'; -import { assetsChart } from './charts/ppmt_assets.js'; -import { profitChart } from './charts/ppmt_profit.js'; -import { ipChart } from './charts/ppmt_ip.js'; -import { categoryChart } from './charts/ppmt_category.js'; -import { sankeyChart } from './charts/ppmt_sankey.js'; -import { regionChannelYoyChart } from './charts/ppmt_region_yoy.js'; -import { ppmtGrowthChart } from './charts/ppmt_growth.js'; -import { ppmtMarginsChart } from './charts/ppmt_margins.js'; -import { ppmtBridgeChart } from './charts/ppmt_bridge.js'; -import { ppmtCoreProfitChart } from './charts/ppmt_core_profit.js'; -import { ppmtCoreMarginChart } from './charts/ppmt_core_margin.js'; -import { ppmtIpShareChart } from './charts/ppmt_ip_share.js'; -import { ppmtIpShareHistoryChart } from './charts/ppmt_ip_share_history.js'; -import { ppmtIpExMonstersChart } from './charts/ppmt_ip_ex_monsters.js'; - -/** 图表清单:加新图只需在这里加一行 */ -const CHARTS = [ - // 示例图表 - { id: 'chart-inventory', csv: 'data/inventory.csv', build: inventoryChart, height: 620 }, - { id: 'chart-margin', csv: 'data/margin.csv', build: marginChart, height: 620 }, - { id: 'chart-twoline', csv: 'data/margin.csv', build: twoLineChart, height: 620 }, - { id: 'chart-growth', csv: 'data/growth.csv', build: growthChart, height: 700 }, - { id: 'chart-fx', csv: 'data/fx.csv', build: fxChart, height: 780 }, - // 泡泡玛特图表 - { id: 'chart-ppmt-assets', csv: 'data/ppmt_assets.csv', build: assetsChart, height: 680 }, - { id: 'chart-ppmt-profit', csv: 'data/ppmt_profit.csv', build: profitChart, height: 680 }, - { id: 'chart-ppmt-ip', csv: 'data/ppmt_ip.csv', build: ipChart, height: 720 }, - { id: 'chart-ppmt-category', csv: 'data/ppmt_category.csv', build: categoryChart, height: 680 }, - { id: 'chart-ppmt-sankey', csv: 'data/ppmt_sankey_2026h1.csv', build: sankeyChart, height: 800 }, - { id: 'chart-ppmt-region-yoy', csv: 'data/ppmt_region_channel_yoy.csv', build: regionChannelYoyChart, height: 620 }, - { id: 'chart-ppmt-growth', csv: 'data/ppmt_growth.csv', build: ppmtGrowthChart, height: 700 }, - { id: 'chart-ppmt-margins', csv: 'data/ppmt_margins.csv', build: ppmtMarginsChart, height: 680 }, - { id: 'chart-ppmt-bridge', csv: 'data/ppmt_margin_bridge.csv', build: ppmtBridgeChart, height: 700 }, - { id: 'chart-ppmt-core', csv: 'data/ppmt_core_profit.csv', build: ppmtCoreProfitChart, height: 720 }, - { id: 'chart-ppmt-core-margin', csv: 'data/ppmt_core_margin.csv', build: ppmtCoreMarginChart, height: 680 }, - { id: 'chart-ppmt-ip-share', csv: 'data/ppmt_ip_share_2026h1.csv', build: ppmtIpShareChart, height: 720 }, - { id: 'chart-ppmt-ip-share-history', csv: 'data/ppmt_ip_share_history.csv', build: ppmtIpShareHistoryChart, height: 700 }, - { id: 'chart-ppmt-ip-ex-monsters', csv: 'data/ppmt_ip_ex_monsters.csv', build: ppmtIpExMonstersChart, height: 700 }, -]; +import { getChartBuilder } from './charts/index.js'; +import { + createAppShell, + renderAnchors, + renderCards, + renderCompanies, + renderTabs, + showChartError, +} from './ui.js'; const instances = new Map(); +const activeViews = new Map(); +let manifest; +let shell; +let state; +let renderVersion = 0; +let cacheToken = Date.now(); -async function render(spec) { - const el = document.getElementById(spec.id); +function validateManifest(value) { + if (!Array.isArray(value?.companies) || value.companies.length === 0) { + throw new Error('manifest 缺少 companies'); + } + + const chartIds = new Set(); + value.companies.forEach((company) => { + if (!company.id || !Array.isArray(company.tabs)) throw new Error('manifest 中存在无效公司配置'); + const tabIds = new Set(); + company.tabs.forEach((tab) => { + if (!tab.id || tabIds.has(tab.id)) throw new Error(`公司 ${company.id} 的 tab id 重复或为空`); + tabIds.add(tab.id); + (tab.charts || []).forEach((chart) => { + if (!chart.id || chartIds.has(chart.id)) throw new Error(`图表 id 重复或为空:${chart.id || '(空)'}`); + if (!getChartBuilder(chart.key)) throw new Error(`图表 ${chart.id} 引用了未注册 key:${chart.key}`); + if (Boolean(chart.csv) === Boolean(chart.views)) throw new Error(`图表 ${chart.id} 必须声明 csv 或 views 之一`); + if (chart.views && (!chart.defaultView || !chart.views[chart.defaultView])) { + throw new Error(`图表 ${chart.id} 的 defaultView 无效`); + } + chartIds.add(chart.id); + }); + }); + }); +} + +function getCompany(companyId) { + return manifest.companies.find((company) => company.id === companyId) || manifest.companies[0]; +} + +function getTab(company, tabId) { + return company.tabs.find((tab) => tab.id === tabId) || company.tabs[0]; +} + +function parseHash() { + const [path = '', query = ''] = window.location.hash.replace(/^#\/?/, '').split('?'); + const [companyId, tabId, chartId] = path.split('/').filter(Boolean).map(decodeURIComponent); + const params = new URLSearchParams(query); + return { companyId, tabId, chartId, view: params.get('view') || undefined }; +} + +function normalizeState(nextState) { + const company = getCompany(nextState.companyId); + const tab = getTab(company, nextState.tabId); + const chart = tab.charts.find((item) => item.id === nextState.chartId); + const view = chart?.views && chart.views[nextState.view] + ? nextState.view + : chart?.defaultView; + if (chart?.views && view) activeViews.set(chart.id, view); + return { companyId: company.id, tabId: tab.id, chartId: chart?.id, view }; +} + +function serializeHash(nextState) { + const path = [nextState.companyId, nextState.tabId, nextState.chartId] + .filter(Boolean) + .map(encodeURIComponent) + .join('/'); + return `#/${path}${nextState.view ? `?view=${encodeURIComponent(nextState.view)}` : ''}`; +} + +function disposeAll() { + instances.forEach((chart) => chart.dispose()); + instances.clear(); +} + +function chartSource(chart) { + if (!chart.views) return chart.csv; + const view = activeViews.get(chart.id) || chart.defaultView; + return chart.views[view]; +} + +async function renderChart(chart, version) { + const el = document.getElementById(`chart-${chart.id}`); if (!el) return; + const source = chartSource(chart); try { - // 加时间戳绕过缓存,改完 CSV 刷新就能看到 - const rows = await loadCSV(`${spec.csv}?t=${Date.now()}`); - const chart = instances.get(spec.id) || echarts.init(el, null, { renderer: 'canvas' }); - instances.set(spec.id, chart); + const rows = await loadCSV(`${source}?t=${cacheToken}`); + if (version !== renderVersion || !document.body.contains(el)) return; + const instance = echarts.init(el, null, { renderer: 'canvas' }); + instances.set(chart.id, instance); const { width, height } = el.getBoundingClientRect(); - chart.setOption(spec.build(rows, { width, height }), true); - } catch (err) { - el.innerHTML = `
${err.message}
`; - console.error(spec.id, err); + instance.setOption(getChartBuilder(chart.key)(rows, { width, height }), true); + } catch (error) { + if (version === renderVersion) showChartError(chart.id, error.message); + console.error(`渲染图表失败:${chart.id}`, error); } } -async function renderAll() { - await Promise.all(CHARTS.map(render)); +function exportPNG(chartId, filename) { + const instance = instances.get(chartId); + if (!instance) return; + const anchor = document.createElement('a'); + anchor.href = instance.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff' }); + anchor.download = `${filename}.png`; + anchor.click(); } -/** 导出当前图为 PNG,2 倍图,微信公众号里够清晰 */ -function exportPNG(id, filename) { - const chart = instances.get(id); - if (!chart) return; - const url = chart.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff' }); - const a = document.createElement('a'); - a.href = url; - a.download = `${filename}.png`; - a.click(); +function updateHash(nextState) { + const hash = serializeHash(nextState); + if (window.location.hash === hash) { + state = normalizeState(nextState); + renderCurrent(); + return; + } + window.location.hash = hash; } -document.addEventListener('DOMContentLoaded', () => { - renderAll(); +function selectAnchor(chartId) { + state = { ...state, chartId, view: activeViews.get(chartId) }; + history.replaceState(null, '', serializeHash(state)); + document.getElementById(`card-${chartId}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); +} - document.getElementById('btn-reload').addEventListener('click', renderAll); +function renderCurrent() { + state = normalizeState(state); + const company = getCompany(state.companyId); + const tab = getTab(company, state.tabId); + const version = ++renderVersion; + disposeAll(); - document.querySelectorAll('[data-export]').forEach((btn) => { - btn.addEventListener('click', () => { - exportPNG(btn.dataset.export, btn.dataset.filename || btn.dataset.export); + renderCompanies(shell.companyNav, manifest.companies, company.id, (companyId) => { + updateHash({ companyId }); + }); + renderTabs(shell.tabs, company.tabs, tab.id, (tabId) => { + updateHash({ companyId: company.id, tabId }); + }); + renderCards(shell.cards, tab.charts || [], activeViews, { + onExport: exportPNG, + onSelectView: (chartId, view) => { + activeViews.set(chartId, view); + updateHash({ companyId: company.id, tabId: tab.id, chartId, view }); + }, + }); + renderAnchors(shell.chartNav, tab.charts || [], selectAnchor); + Promise.all((tab.charts || []).map((chart) => renderChart(chart, version))); +} + +async function start() { + try { + const response = await fetch('manifest.json'); + if (!response.ok) throw new Error(`读取 manifest 失败:${response.status}`); + manifest = await response.json(); + validateManifest(manifest); + shell = createAppShell(document.getElementById('app'), { + onReload: () => { + cacheToken = Date.now(); + renderCurrent(); + }, }); - }); + state = normalizeState(parseHash()); + if (!window.location.hash) history.replaceState(null, '', serializeHash(state)); + renderCurrent(); + } catch (error) { + document.getElementById('app').textContent = `初始化失败:${error.message}`; + console.error(error); + } +} - let timer; - window.addEventListener('resize', () => { - clearTimeout(timer); - // resize 后水印数量要跟着画布变,所以整体重绘而不是 chart.resize() - timer = setTimeout(renderAll, 200); - }); +document.addEventListener('DOMContentLoaded', start); + +window.addEventListener('hashchange', () => { + if (!manifest) return; + state = normalizeState(parseHash()); + renderCurrent(); +}); + +let resizeTimer; +window.addEventListener('resize', () => { + if (!shell) return; + clearTimeout(resizeTimer); + resizeTimer = setTimeout(renderCurrent, 200); }); diff --git a/js/ui.js b/js/ui.js new file mode 100644 index 0000000..0dec99f --- /dev/null +++ b/js/ui.js @@ -0,0 +1,122 @@ +function element(tag, className, text) { + const el = document.createElement(tag); + if (className) el.className = className; + if (text !== undefined) el.textContent = text; + return el; +} + +function button(label, { active = false, onClick, className = '' } = {}) { + const el = element('button', className, label); + el.type = 'button'; + el.dataset.active = String(active); + if (onClick) el.addEventListener('click', onClick); + return el; +} + +export function createAppShell(root, { onReload }) { + root.replaceChildren(); + + const header = element('header', 'app-header'); + const brand = element('div', 'app-brand'); + brand.append(element('h1', '', '财报图表'), element('p', '', '选择公司与分析维度,数据更新后可重新加载。')); + header.append(brand, button('重新加载数据', { onClick: onReload, className: 'reload-button' })); + + const layout = element('div', 'app-layout'); + const companyNav = element('aside', 'sidebar company-sidebar'); + const content = element('main', 'content'); + const tabs = element('nav', 'tabs'); + const cards = element('div', 'cards'); + content.append(tabs, cards); + const chartNav = element('aside', 'sidebar chart-sidebar'); + layout.append(companyNav, content, chartNav); + root.append(header, layout); + + return { companyNav, tabs, cards, chartNav }; +} + +export function renderCompanies(container, companies, activeCompanyId, onSelect) { + container.replaceChildren(element('h2', 'sidebar-title', '公司')); + const list = element('div', 'company-list'); + companies.forEach((company) => { + list.append(button(company.name, { + active: company.id === activeCompanyId, + onClick: () => onSelect(company.id), + className: 'company-button', + })); + }); + container.append(list); +} + +export function renderTabs(container, tabs, activeTabId, onSelect) { + container.replaceChildren(); + tabs.forEach((tab) => { + container.append(button(tab.name, { + active: tab.id === activeTabId, + onClick: () => onSelect(tab.id), + className: 'tab-button', + })); + }); +} + +export function renderCards(container, charts, activeViews, { onExport, onSelectView }) { + container.replaceChildren(); + if (!charts.length) { + container.append(element('p', 'empty-state', '该分类暂时没有图表。')); + return; + } + + charts.forEach((chart) => { + const card = element('section', 'card'); + card.id = `card-${chart.id}`; + const bar = element('div', 'card-bar'); + const heading = element('div', 'card-heading'); + heading.append(element('h2', 'card-title', chart.title)); + + const viewNames = Object.keys(chart.views || {}); + const activeView = activeViews.get(chart.id) || chart.defaultView || viewNames[0]; + const source = chart.views ? chart.views[activeView] : chart.csv; + heading.append(element('span', 'src', source)); + bar.append(heading); + + if (viewNames.length > 1) { + const switcher = element('div', 'view-switcher'); + const labels = { annual: '年度', halfyear: '半年', quarter: '季度' }; + viewNames.forEach((view) => { + switcher.append(button(labels[view] || view, { + active: view === activeView, + onClick: () => onSelectView(chart.id, view), + className: 'view-button', + })); + }); + bar.append(switcher); + } + + bar.append(button('导出 PNG', { + onClick: () => onExport(chart.id, chart.filename || chart.title), + className: 'export-button', + })); + const chartEl = element('div', 'chart'); + chartEl.id = `chart-${chart.id}`; + chartEl.style.height = `${chart.height || 680}px`; + card.append(bar, chartEl); + container.append(card); + }); +} + +export function renderAnchors(container, charts, onSelect) { + container.replaceChildren(element('h2', 'sidebar-title', '当前图表')); + const list = element('div', 'anchor-list'); + charts.forEach((chart) => { + container.append(button(chart.title, { + onClick: () => onSelect(chart.id), + className: 'anchor-button', + })); + }); + if (!charts.length) container.append(element('p', 'sidebar-empty', '暂无图表')); +} + +export function showChartError(chartId, message) { + const chartEl = document.getElementById(`chart-${chartId}`); + if (!chartEl) return; + chartEl.replaceChildren(element('div', 'chart-error', message)); +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..79f01e4 --- /dev/null +++ b/manifest.json @@ -0,0 +1,101 @@ +{ + "companies": [ + { + "id": "ppmt", + "name": "泡泡玛特", + "tabs": [ + { + "id": "overview", + "name": "概览", + "charts": [ + { "id": "ppmt-profit", "key": "ppmt-profit", "title": "盈利能力", "csv": "data/ppmt/ppmt_profit.csv", "height": 680, "filename": "泡泡玛特盈利能力" }, + { "id": "ppmt-sankey", "key": "ppmt-sankey", "title": "利润流向", "csv": "data/ppmt/ppmt_sankey_2026h1.csv", "height": 800, "filename": "泡泡玛特利润流向2026H1" } + ] + }, + { + "id": "growth", + "name": "成长性", + "charts": [ + { "id": "ppmt-growth", "key": "ppmt-growth", "title": "营收到利润的增速对比", "csv": "data/ppmt/ppmt_growth.csv", "height": 700, "filename": "泡泡玛特营收到利润增速对比" } + ] + }, + { + "id": "profitability", + "name": "盈利能力", + "charts": [ + { "id": "ppmt-core-profit", "key": "ppmt-core-profit", "title": "核心利润及同比增速", "csv": "data/ppmt/ppmt_core_profit.csv", "height": 720, "filename": "泡泡玛特核心利润及同比增速" }, + { "id": "ppmt-core-margin", "key": "ppmt-core-margin", "title": "核心利润率", "csv": "data/ppmt/ppmt_core_margin.csv", "height": 680, "filename": "泡泡玛特核心利润率" }, + { "id": "ppmt-bridge", "key": "ppmt-bridge", "title": "净利率同比拆解", "csv": "data/ppmt/ppmt_margin_bridge.csv", "height": 700, "filename": "泡泡玛特净利率同比拆解" } + ] + }, + { + "id": "expense", + "name": "费用率", + "charts": [ + { "id": "ppmt-margins", "key": "ppmt-margins", "title": "利润率与费用率拆解", "csv": "data/ppmt/ppmt_margins.csv", "height": 680, "filename": "泡泡玛特利润率与费用率拆解" } + ] + }, + { + "id": "balance-sheet", + "name": "资产负债", + "charts": [ + { "id": "ppmt-assets", "key": "ppmt-assets", "title": "资产结构演变", "csv": "data/ppmt/ppmt_assets.csv", "height": 680, "filename": "泡泡玛特资产结构" } + ] + }, + { + "id": "ip-category", + "name": "IP 与品类", + "charts": [ + { "id": "ppmt-ip", "key": "ppmt-ip", "title": "主要 IP 收入趋势", "csv": "data/ppmt/ppmt_ip.csv", "height": 720, "filename": "泡泡玛特IP收入趋势" }, + { "id": "ppmt-ip-share", "key": "ppmt-ip-share", "title": "主要 IP 收入占比", "csv": "data/ppmt/ppmt_ip_share_2026h1.csv", "height": 720, "filename": "泡泡玛特IP收入占比2026H1" }, + { "id": "ppmt-ip-share-history", "key": "ppmt-ip-share-history", "title": "各 IP 半年收入占比", "csv": "data/ppmt/ppmt_ip_share_history.csv", "height": 700, "filename": "泡泡玛特各IP半年收入占比" }, + { "id": "ppmt-ip-ex-monsters", "key": "ppmt-ip-ex-monsters", "title": "除 THE MONSTERS 外的 IP 收入及同比", "csv": "data/ppmt/ppmt_ip_ex_monsters.csv", "height": 700, "filename": "泡泡玛特除MONSTERS外IP收入及同比" }, + { "id": "ppmt-category", "key": "ppmt-category", "title": "品类收入结构", "csv": "data/ppmt/ppmt_category.csv", "height": 680, "filename": "泡泡玛特品类结构" } + ] + }, + { + "id": "region-channel", + "name": "区域渠道", + "charts": [ + { "id": "ppmt-region-yoy", "key": "ppmt-region-yoy", "title": "区域渠道同比增速", "csv": "data/ppmt/ppmt_region_channel_yoy.csv", "height": 620, "filename": "泡泡玛特区域渠道同比增速" } + ] + } + ] + }, + { + "id": "demo", + "name": "示例", + "tabs": [ + { + "id": "operations", + "name": "营运", + "charts": [ + { "id": "demo-inventory", "key": "demo-inventory", "title": "存货、营收与存货周转天数", "csv": "data/demo/inventory.csv", "height": 620, "filename": "存货与周转天数" } + ] + }, + { + "id": "profitability", + "name": "盈利能力", + "charts": [ + { "id": "demo-margin", "key": "demo-margin", "title": "利润率与费用率拆解", "csv": "data/demo/margin.csv", "height": 620, "filename": "利润率与费用率拆解" }, + { "id": "demo-twoline", "key": "demo-twoline", "title": "毛利率与非国际净利率", "csv": "data/demo/margin.csv", "height": 620, "filename": "毛利率与非国际净利率" } + ] + }, + { + "id": "growth", + "name": "成长性", + "charts": [ + { "id": "demo-growth", "key": "demo-growth", "title": "营收到利润的增速对比", "csv": "data/demo/growth.csv", "height": 700, "filename": "营收到利润的增速对比" } + ] + }, + { + "id": "market", + "name": "汇率", + "charts": [ + { "id": "demo-fx", "key": "demo-fx", "title": "人民币汇率两种口径", "csv": "data/demo/fx.csv", "height": 780, "filename": "人民币汇率两种口径" } + ] + } + ] + } + ] +} diff --git a/test/regress.mjs b/test/regress.mjs index f92f549..6da81c1 100644 --- a/test/regress.mjs +++ b/test/regress.mjs @@ -1,98 +1,100 @@ import * as echarts from 'echarts'; -import fs from 'fs'; -// 项目根目录(从 test/regress.mjs 向上一层,或直接指定绝对路径) -const P = '/Users/joey/sites/vest-tools/echart-demo/'; -const {parseCSV}=await import(P+'js/csv.js'); -const all=[ - ['inventory','inventoryChart','data/inventory.csv'], - ['margin','marginChart','data/margin.csv'], - ['twoline','twoLineChart','data/margin.csv'], - ['growth','growthChart','data/growth.csv'], - ['fx','fxChart','data/fx.csv'], - ['ppmt_assets','assetsChart','data/ppmt_assets.csv'], - ['ppmt_profit','profitChart','data/ppmt_profit.csv'], - ['ppmt_ip','ipChart','data/ppmt_ip.csv'], - ['ppmt_category','categoryChart','data/ppmt_category.csv'], - ['ppmt_sankey','sankeyChart','data/ppmt_sankey_2026h1.csv'], - ['ppmt_region_yoy','regionChannelYoyChart','data/ppmt_region_channel_yoy.csv'], - ['ppmt_growth','ppmtGrowthChart','data/ppmt_growth.csv'], - ['ppmt_margins','ppmtMarginsChart','data/ppmt_margins.csv'], - ['ppmt_bridge','ppmtBridgeChart','data/ppmt_margin_bridge.csv'], - ['ppmt_core_profit','ppmtCoreProfitChart','data/ppmt_core_profit.csv'], - ['ppmt_core_margin','ppmtCoreMarginChart','data/ppmt_core_margin.csv'], - ['ppmt_ip_share','ppmtIpShareChart','data/ppmt_ip_share_2026h1.csv'], - ['ppmt_ip_share_history','ppmtIpShareHistoryChart','data/ppmt_ip_share_history.csv'], - ['ppmt_ip_ex_monsters','ppmtIpExMonstersChart','data/ppmt_ip_ex_monsters.csv'], -]; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseCSV } from '../js/csv.js'; +import { getChartBuilder } from '../js/charts/index.js'; -// 可传图表名只跑指定的(改一张图不用全量回归): -// node test/regress.mjs ppmt_ip_share ppmt_core_margin -const only=process.argv.slice(2); -const targets=only.length>0 ? all.filter(([f])=>only.includes(f)) : all; -if(only.length>0){ - const missing=only.filter(f=>!all.some(([name])=>name===f)); - if(missing.length>0) console.log(`⚠ 未找到图表:${missing.join(', ')}`); -} - -// 样式规范断言:从核心利润/利润率拆解图总结的标准 -const STYLE_RULES={ - legendTop:85, // 固定值:副标题到图例约25px - gridTop:145, // 固定值:图例到绘图区约60px - lineSeries:{ // 折线标准样式(空心圆点) - symbolSize:9, - borderWidth:2.5, - fill:'#fff', // 白心 - }, +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'manifest.json'), 'utf8')); +const STYLE_RULES = { + legendTop: 85, + gridTop: 145, + lineSeries: { symbolSize: 9, borderWidth: 2.5 }, }; -let ok=0,fail=0,styleWarnings=[]; -for(const [f,fn,csv] of targets){ - try{ - const mod=await import(P+`js/charts/${f}.js`); - const rows=parseCSV(fs.readFileSync(P+csv,'utf8')); - const c=echarts.init(null,null,{renderer:'svg',ssr:true,width:1100,height:720}); - const opt=mod[fn](rows,{width:1100,height:720}); - c.setOption(opt); - const svg=c.renderToSVGString(); - - // 1. SVG 格式检查(字体引号) - const badAttr=/style="[^"]*"[^"=>\s][^>]*>/.test(svg.slice(0,3000)); - - // 2. 样式规范检查(跳过无图例的图表) - if(opt.legend?.top!==undefined){ - if(opt.legend.top!==STYLE_RULES.legendTop){ - styleWarnings.push(`${f}: legend.top=${opt.legend.top} (期望${STYLE_RULES.legendTop})`); - } +function entriesFromManifest(value) { + const ids = new Set(); + const entries = []; + value.companies.forEach((company) => company.tabs.forEach((tab) => (tab.charts || []).forEach((chart) => { + if (!chart.id || ids.has(chart.id)) throw new Error(`manifest 图表 id 重复或为空:${chart.id || '(空)'}`); + ids.add(chart.id); + if (!getChartBuilder(chart.key)) throw new Error(`manifest 图表 ${chart.id} 引用了未注册 key:${chart.key}`); + if (Boolean(chart.csv) === Boolean(chart.views)) { + throw new Error(`manifest 图表 ${chart.id} 必须声明 csv 或 views 之一`); } - if(opt.grid?.top!==undefined && opt.legend?.top!==undefined){ - if(opt.grid.top!==STYLE_RULES.gridTop){ - styleWarnings.push(`${f}: grid.top=${opt.grid.top} (期望${STYLE_RULES.gridTop})`); - } + if (chart.views && (!chart.defaultView || !chart.views[chart.defaultView])) { + throw new Error(`manifest 图表 ${chart.id} 的 defaultView 无效`); } - - // 3. 折线圆点样式检查(在 SVG 里验证 symbolSize 和 borderWidth) - const circles=[...svg.matchAll(/0){ - const scale=+circles[0][1], sw=+circles[0][2]; - const symbolSize=scale*2, borderWidth=sw*scale; - if(Math.abs(symbolSize-STYLE_RULES.lineSeries.symbolSize)>0.1){ - styleWarnings.push(`${f}: 折线 symbolSize=${symbolSize.toFixed(1)} (期望${STYLE_RULES.lineSeries.symbolSize})`); - } - if(Math.abs(borderWidth-STYLE_RULES.lineSeries.borderWidth)>0.1){ - styleWarnings.push(`${f}: 折线 borderWidth=${borderWidth.toFixed(1)} (期望${STYLE_RULES.lineSeries.borderWidth})`); - } + const sources = chart.views + ? Object.entries(chart.views).map(([view, csv]) => ({ view, csv })) + : [{ view: null, csv: chart.csv }]; + if (!sources.every(({ csv }) => typeof csv === 'string' && csv.length > 0)) { + throw new Error(`manifest 图表 ${chart.id} 缺少 CSV`); } - - console.log(`${badAttr?'⚠':'✓'} ${f.padEnd(17)} ${String(svg.length).padStart(6)}B`); - ok++; c.dispose(); - }catch(e){console.log(`✗ ${f.padEnd(17)} ${e.message}`);fail++;} + sources.forEach(({ view, csv }) => entries.push({ ...chart, company, tab, view, csv })); + }))); + return entries; } -console.log(`\n${ok} 张成功 / ${fail} 张失败`); -if(styleWarnings.length>0){ +const all = entriesFromManifest(manifest); +const only = process.argv.slice(2); +const targets = only.length === 0 + ? all + : all.filter((entry) => only.includes(entry.id) || only.includes(entry.key)); +const unknown = only.filter((target) => !all.some((entry) => entry.id === target || entry.key === target)); + +if (unknown.length) console.log(`⚠ 未找到图表:${unknown.join(', ')}`); + +let ok = 0; +let fail = 0; +const styleWarnings = []; + +for (const entry of targets) { + const label = entry.view ? `${entry.id}:${entry.view}` : entry.id; + try { + const csvPath = path.resolve(ROOT, entry.csv); + if (!csvPath.startsWith(`${ROOT}${path.sep}`)) throw new Error(`CSV 路径越过项目根目录:${entry.csv}`); + const rows = parseCSV(fs.readFileSync(csvPath, 'utf8')); + const chart = echarts.init(null, null, { renderer: 'svg', ssr: true, width: 1100, height: 720 }); + const option = getChartBuilder(entry.key)(rows, { width: 1100, height: 720 }); + chart.setOption(option); + const svg = chart.renderToSVGString(); + chart.dispose(); + + const badAttr = /style="[^"]*"[^"=>\s][^>]*>/.test(svg.slice(0, 3000)); + if (option.legend?.top !== undefined && option.legend.top !== STYLE_RULES.legendTop) { + styleWarnings.push(`${label}: legend.top=${option.legend.top} (期望 ${STYLE_RULES.legendTop})`); + } + if (option.grid?.top !== undefined && option.legend?.top !== undefined && option.grid.top !== STYLE_RULES.gridTop) { + styleWarnings.push(`${label}: grid.top=${option.grid.top} (期望 ${STYLE_RULES.gridTop})`); + } + const circles = [...svg.matchAll(/ 0) { + const scale = +circles[0][1]; + const strokeWidth = +circles[0][2]; + if (Math.abs(scale * 2 - STYLE_RULES.lineSeries.symbolSize) > 0.1) { + styleWarnings.push(`${label}: 折线 symbolSize=${(scale * 2).toFixed(1)} (期望 ${STYLE_RULES.lineSeries.symbolSize})`); + } + if (Math.abs(strokeWidth * scale - STYLE_RULES.lineSeries.borderWidth) > 0.1) { + styleWarnings.push(`${label}: 折线 borderWidth=${(strokeWidth * scale).toFixed(1)} (期望 ${STYLE_RULES.lineSeries.borderWidth})`); + } + } + + console.log(`${badAttr ? '⚠' : '✓'} ${label.padEnd(30)} ${String(svg.length).padStart(6)}B`); + ok += 1; + } catch (error) { + console.log(`✗ ${label.padEnd(30)} ${error.message}`); + fail += 1; + } +} + +console.log(`\n${ok} 张成功 / ${fail} 张失败`); +if (styleWarnings.length) { console.log(`\n⚠ ${styleWarnings.length} 处样式偏离规范:`); - styleWarnings.forEach(w=>console.log(` ${w}`)); -}else{ + styleWarnings.forEach((warning) => console.log(` ${warning}`)); +} else { console.log('✓ 样式规范全部符合'); } -process.exit(fail>0?1:0); + +process.exit(fail > 0 || unknown.length > 0 ? 1 : 0);