// Overview.jsx — 二维俯瞰网格(纵轴:时间尺 / 地点序 · 横轴:本子)
// 由 Claude Design「坐标模型共识」原型重建到生产数据模型。覆层叠加,不改现有流程。
// 列归属用现有 noteInNotebook(含子本子下钻);纵轴时间用 import_ts/date 真实刻度。
const { useState: useStateOv } = React;
// 时间值(毫秒):date "2021.10.14" / "2026.6.9"(容许不补零)+ time "16:00"
function ovTval(n) {
const p = String(n.date || '').split('.').map(x => parseInt(x, 10));
if (!p[0]) return 0;
const tm = /^(\d{1,2}):(\d{2})/.exec(n.time || '');
return new Date(p[0], (p[1] || 1) - 1, p[2] || 1, tm ? +tm[1] : 12, tm ? +tm[2] : 0).getTime();
}
const OV_GUTTER = 60; // 左侧时间/地点标尺宽
const OV_COLW = 152; // 每个本子列宽
// 一张笔记卡(列内)
function OvChip({ note, dim, onOpen, style }) {
const color = note._capLanding ? 'var(--flame)' : ((window.CITY_LINE && window.CITY_LINE[note.city]) || 'var(--ink-3)');
return (
);
}
function OverviewGrid({ axis, notes, notebooks, timeFilter, placeFilter, notebookFilter, setNotebookFilter, onOpenNote, onClose }) {
const [scale, setScale] = useStateOv(1);
const rowMode = axis === 'place' ? 'place' : 'time'; // 时间/时空 → 时间尺;地点 → 地点序
const NOW = window.NOW || { date: '2026.06.01', time: '11:15', city: '上海', scene: '家' };
const cols = (notebooks || []).filter(nb => !nb.parent); // 横轴 = 顶级本子
const colIndex = {}; cols.forEach((c, i) => colIndex[c.id] = i);
// 每条笔记归到哪个顶级本子列(用现有 noteInNotebook,含子本子下钻)
const colCache = new Map();
const colOf = n => {
if (colCache.has(n.id)) return colCache.get(n.id);
let id = null;
for (const c of cols) { if (window.noteInNotebook(n, notebooks, c.id)) { id = c.id; break; } }
colCache.set(n.id, id);
return id;
};
// 行方向收窄(共享过滤器:时间 年/月 / 地点 城/年)。本子方向是横轴,不在此过滤。
const rows = (notes || []).filter(n => {
if (rowMode === 'time' && timeFilter.year && n.year !== timeFilter.year) return false;
if (rowMode === 'time' && timeFilter.month && n.month !== timeFilter.month) return false;
if (rowMode === 'place' && placeFilter.city && n.city !== placeFilter.city) return false;
if (rowMode === 'place' && placeFilter.year && n.year !== placeFilter.year) return false;
return colOf(n); // 没有本子归属的(极少)不进网格
});
const colHas = {}; rows.forEach(n => { const c = colOf(n); if (c) colHas[c] = (colHas[c] || 0) + 1; });
const totalW = OV_GUTTER + cols.length * OV_COLW;
const ctx = rowMode === 'place'
? (placeFilter.city ? '地点 · ' + placeFilter.city + (placeFilter.year ? ' · ' + placeFilter.year : '') : '地点 · 全部')
: (timeFilter.year ? '时间 · ' + timeFilter.year + (timeFilter.month ? ' · ' + timeFilter.month : '') : (axis === 'spacetime' ? '时空 · 全部' : '时间 · 全部'));
// ---------- 列头(横轴本子,点击=锁定/解锁该列) ----------
const colHeader = (
{rowMode === 'place' ? '地点' : '时间'}
{cols.map(c => {
const empty = !colHas[c.id];
const locked = notebookFilter === c.id;
return (
);
})}
);
// ============ 时间尺:按真实刻度铺(近→远,往下) ============
function renderTime() {
const times = rows.map(ovTval).filter(Boolean);
const nowMs = ovTval(NOW);
const tMax = Math.max(nowMs, ...(times.length ? times : [nowMs]));
const tMin = Math.min(nowMs, ...(times.length ? times : [nowMs]));
const span = Math.max(864e5, tMax - tMin);
const yrs = span / (365.25 * 864e5);
const H = Math.max(560, Math.round(yrs * 200 * scale)) + 80;
const yOf = ms => 36 + (tMax - ms) / span * (H - 96);
const y0 = new Date(tMax).getFullYear(), y1 = new Date(tMin).getFullYear();
const years = [];
for (let y = y0; y >= y1; y--) years.push(y);
// 每列内防重叠:同列按 y 排序,过近则下推
const placed = {};
cols.forEach(c => placed[c.id] = []);
[...rows].sort((a, b) => ovTval(b) - ovTval(a)).forEach(n => {
const cid = colOf(n); const arr = placed[cid]; if (!arr) return;
let y = yOf(ovTval(n));
if (arr.length && y - arr[arr.length - 1].y < 40) y = arr[arr.length - 1].y + 40;
arr.push({ note: n, y });
});
const nowY = yOf(nowMs);
return (
{years.map(y => {
const yy = yOf(Date.parse(y + '-01-01T00:00'));
if (yy < 20 || yy > H - 10) return null;
return (
{y}
);
})}
{cols.map(c => (
))}
现在 · {NOW.city}
{cols.map(c => placed[c.id].map(p => (
)))}
);
}
// ============ 地点序:城市分组(行),组内按时间等间距排 ============
function renderPlace() {
const cityList = [...new Set(rows.map(n => n.city).filter(c => c && c !== '未标注'))]
.sort((a, b) => Math.max(...rows.filter(n => n.city === b).map(ovTval)) - Math.max(...rows.filter(n => n.city === a).map(ovTval)));
return (
{cityList.map(city => {
const cityNotes = rows.filter(n => n.city === city);
const color = (window.CITY_LINE && window.CITY_LINE[city]) || 'var(--ink-3)';
return (
{city}
{cityNotes.length}
{cols.map(c => {
const cell = cityNotes.filter(n => colOf(n) === c.id).sort((a, b) => ovTval(a) - ovTval(b));
return (
{cell.map(n => (
))}
);
})}
);
})}
{cityList.length === 0 &&
这段还没有地点信息
}
);
}
return (
{ if (e.target === e.currentTarget) onClose(); }}>
俯瞰 · 时空 × 本子
{ctx} · 纵轴{rowMode === 'place' ? '地点(按先后)' : '时间(按刻度)'} · 横轴本子 · {rows.length} 则
{rowMode === 'time' && (
时间疏密
)}
{colHeader}
{rowMode === 'place' ? renderPlace() : renderTime()}
{rowMode === 'place'
? '城市为行、本子为列;同城内按先后等距排 — 点本子列只看那一本,点卡片回到那一刻'
: '时间自上而下按真实刻度,本子分列;「现在」线之上是尚未到来的计划 — 点卡片回到那一刻'}
);
}
Object.assign(window, { OverviewGrid });