Files
rond-with-you/html/index.html
T
2026-09-19 19:50:27 +08:00

857 lines
57 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>rond-with-you · 演示版</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ctext y='26' font-size='26'%3E%F0%9F%A7%AD%3C/text%3E%3C/svg%3E">
<link rel="stylesheet" href="vendor/leaflet/leaflet.css">
<link rel="stylesheet" href="vendor/markercluster/MarkerCluster.css">
<link rel="stylesheet" href="vendor/markercluster/MarkerCluster.Default.css">
<style>
:root{
--bg:#f5f6f8; --card:#ffffff; --text:#1b1d21; --text-2:#4a4f57; --text-3:#8b9099;
--border:#e7e9ed; --border-2:#eef0f3; --accent:#1c9d57; --accent-2:#15803d;
--accent-soft:rgba(28,157,87,.12); --shadow:0 1px 2px rgba(16,24,40,.04),0 2px 12px rgba(16,24,40,.05);
--radius:13px;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",Roboto,Helvetica,Arial,sans-serif;
background:var(--bg); color:var(--text); font-size:14px; line-height:1.55; -webkit-font-smoothing:antialiased;
}
a{color:var(--accent-2); text-decoration:none}
a:hover{text-decoration:underline}
h1{font-size:21px;margin:0;font-weight:700;letter-spacing:.2px}
h2{font-size:15.5px;margin:0 0 2px;font-weight:650}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:7px 13px;border:1px solid var(--border);background:#fff;color:var(--text);border-radius:9px;font-size:13px;cursor:pointer;transition:.12s}
.btn:hover{background:#f4f6f8;text-decoration:none}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}
.btn.primary:hover{background:var(--accent-2)}
.btn.sm{padding:5px 10px;font-size:12.5px}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.92em}
.muted{color:var(--text-3)}
.num{text-align:right;font-variant-numeric:tabular-nums}
/* nav */
.nav{position:sticky;top:0;z-index:1200;background:rgba(255,255,255,.92);backdrop-filter:saturate(180%) blur(8px);border-bottom:1px solid var(--border)}
.nav-inner{max-width:1080px;margin:0 auto;display:flex;align-items:center;gap:14px;padding:10px 16px}
.brand{display:flex;align-items:center;gap:8px;font-weight:700;color:var(--text);font-size:15px}
.brand:hover{text-decoration:none}
.avatar{width:28px;height:28px;border-radius:9px;background:var(--accent-soft);display:inline-flex;align-items:center;justify-content:center;font-size:16px}
.nav-links{display:flex;gap:2px;overflow-x:auto;scrollbar-width:none;flex:1}
.nav-links::-webkit-scrollbar{display:none}
.nav-links a{padding:6px 11px;border-radius:8px;color:var(--text-2);font-size:13.5px;white-space:nowrap}
.nav-links a:hover{background:#f0f2f5;text-decoration:none}
.nav-links a.on{background:var(--accent-soft);color:var(--accent-2);font-weight:600}
.nav-right{display:flex;align-items:center;gap:8px}
.nav-right .btn{font-size:12.5px}
@media(max-width:520px){
.nav-inner{padding:9px 12px;gap:8px}
.nav-links a{padding:6px 8px;font-size:13px}
.brand{font-size:14px}
.nav-right .btn{padding:5px 9px;font-size:12px}
}
.banner{background:linear-gradient(90deg,#fff7e6,#fff);border-bottom:1px solid #f3e3bf;color:#8a6d1f;font-size:12.5px;text-align:center;padding:6px 12px}
.banner b{color:#a06a00}
.wrap{max-width:1080px;margin:0 auto;padding:18px 16px 60px}
.page-head{display:flex;justify-content:space-between;align-items:flex-end;gap:12px;margin:4px 0 16px;flex-wrap:wrap}
.page-head .sub{color:var(--text-3);font-size:13px;margin-top:2px}
.row{display:flex;gap:10px;flex-wrap:wrap}
.grid{display:grid;gap:16px}
.grid.g2{grid-template-columns:repeat(2,1fr)}
@media(max-width:720px){.grid.g2{grid-template-columns:1fr}}
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:16px;box-shadow:var(--shadow);margin-bottom:16px}
.card-sub{color:var(--text-3);font-size:12.5px;margin-bottom:12px}
.kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
@media(max-width:720px){.kpis{grid-template-columns:repeat(2,1fr)}}
.kpi{background:var(--card);border:1px solid var(--border);border-radius:11px;padding:12px 13px;box-shadow:var(--shadow)}
.kpi .v{font-size:24px;font-weight:750;line-height:1.1;font-variant-numeric:tabular-nums}
.kpi .v small{font-size:13px;font-weight:600;color:var(--text-3);margin-left:2px}
.kpi .k{font-size:12px;color:var(--text-3);margin-top:3px}
/* bars */
.bars{display:flex;flex-direction:column;gap:9px}
.bar-row{display:grid;grid-template-columns:120px 1fr auto;align-items:center;gap:10px;font-size:13px}
.bar-row .nm{color:var(--text-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bar-track{height:9px;background:var(--border-2);border-radius:6px;overflow:hidden}
.bar-fill{height:100%;border-radius:6px;background:var(--accent)}
.bar-row .vv{color:var(--text-3);font-size:12px;white-space:nowrap;font-variant-numeric:tabular-nums}
@media(max-width:520px){.bar-row{grid-template-columns:84px 1fr auto}.bar-row .nm{font-size:12px}}
/* chips */
.chips{display:flex;flex-wrap:wrap;gap:7px}
.chip{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border:1px solid var(--border);border-radius:999px;background:#fff;color:var(--text-2);font-size:12.5px;cursor:pointer}
.chip.on{background:var(--accent-soft);border-color:transparent;color:var(--accent-2);font-weight:600}
.chip .dot{width:9px;height:9px;border-radius:50%;display:inline-block}
/* calendar heatmap */
.heat{display:flex;gap:3px;overflow-x:auto;padding-bottom:4px}
.wk{display:flex;flex-direction:column;gap:3px}
.d{width:12px;height:12px;border-radius:3px;background:#ebedf0}
.d.l1{background:#cdeed6}.d.l2{background:#9bdcb0}.d.l3{background:#5cc488}.d.l4{background:#1c9d57}
.heat-months{display:flex;gap:3px;margin-top:4px;font-size:10.5px;color:var(--text-3)}
.heat-months span{width:12px}
.heat-months .ml{width:auto;white-space:nowrap;margin-right:14px}
.heat-legend{display:flex;align-items:center;gap:4px;font-size:11.5px;color:var(--text-3);margin-top:10px}
/* monthly / hourly cols */
.cols{display:flex;align-items:flex-end;gap:3px;height:120px}
.col{flex:1;background:var(--accent);border-radius:3px 3px 0 0;min-height:2px;opacity:.85;transition:.15s}
.col:hover{opacity:1}
.cols-x{display:flex;gap:3px;margin-top:5px;font-size:10px;color:var(--text-3)}
.cols-x span{flex:1;text-align:center;white-space:nowrap;overflow:hidden}
/* tables */
.tw{overflow-x:auto}
.tb{width:100%;border-collapse:collapse;font-size:13px}
.tb th,.tb td{padding:8px 10px;border-bottom:1px solid var(--border-2);text-align:left}
.tb th{color:var(--text-3);font-weight:600;font-size:12px}
.tb tbody tr:hover{background:#fafbfc}
.tag{display:inline-block;padding:1px 7px;border-radius:999px;background:var(--accent-soft);color:var(--accent-2);font-size:11.5px;margin-right:3px}
/* list (recent) */
.list{display:flex;flex-direction:column}
.item{display:flex;gap:11px;align-items:center;padding:10px 0;border-bottom:1px solid var(--border-2)}
.item:last-child{border-bottom:none}
.item .ic{width:34px;height:34px;border-radius:9px;display:inline-flex;align-items:center;justify-content:center;font-size:17px;flex-shrink:0}
.item .t{font-weight:600}
.item .m{font-size:12px;color:var(--text-3)}
.item .r{margin-left:auto;text-align:right;font-size:12.5px;font-weight:600;white-space:nowrap}
.item .r .muted{font-weight:400}
/* map */
.map-page{position:relative;height:calc(100vh - 111px);min-height:420px}
#map{position:absolute;inset:0;border-radius:0}
.map-preview{height:340px;border-radius:11px;overflow:hidden;border:1px solid var(--border);cursor:pointer}
.map-panel{position:absolute;top:12px;left:12px;z-index:800;width:268px;max-height:calc(100% - 24px);overflow-y:auto;background:#fff;border:1px solid var(--border);border-radius:12px;box-shadow:var(--shadow);padding:13px}
.map-panel h3{font-size:12.5px;margin:14px 0 7px;color:var(--text-2)}
.map-panel .row{display:flex;gap:7px}
.map-panel input,.map-panel select{width:100%;padding:7px 9px;border:1px solid var(--border);border-radius:8px;font-size:13px;background:#fff;color:var(--text)}
.map-panel .filter-actions{margin-top:12px}
.map-panel .map-count{font-size:12px;margin-top:11px}
.panel-fold{display:none;width:100%;align-items:center;justify-content:space-between;gap:8px;padding:9px 11px;border:1px solid var(--border);background:#fff;border-radius:10px;font-size:13px;font-weight:600;cursor:pointer;color:var(--text)}
.panel-fold .pf-n{font-weight:400;color:var(--text-3);font-size:12px}
.panel-fold .pf-c{color:var(--text-3)}
.map-legend{position:absolute;right:12px;bottom:12px;z-index:800;background:#fff;border:1px solid var(--border);border-radius:11px;box-shadow:var(--shadow);padding:11px 13px;font-size:12.5px;max-width:200px}
.map-legend .lg{display:flex;align-items:center;gap:7px;margin-top:5px;color:var(--text-2)}
.map-legend .lg i{width:10px;height:10px;border-radius:3px;display:inline-block}
.timeline-bar{position:absolute;left:50%;transform:translateX(-50%);bottom:14px;z-index:800;display:flex;align-items:center;gap:10px;background:#fff;border:1px solid var(--border);border-radius:999px;box-shadow:var(--shadow);padding:8px 14px;width:min(560px,calc(100% - 280px))}
.timeline-bar input[type=range]{flex:1}
.tl-play,.tl-reset{border:1px solid var(--border);background:#fff;border-radius:8px;padding:3px 9px;cursor:pointer;font-size:12.5px}
.tl-label{font-size:12px;color:var(--text-2);min-width:130px;text-align:right}
@media(max-width:720px){
.map-panel{width:calc(100% - 24px)}
.panel-fold{display:flex}
.map-panel.collapsed .panel-body{display:none}
.map-panel.collapsed h3{display:none}
.timeline-bar{width:calc(100% - 24px)}
.map-legend{display:none}
}
.leaflet-popup-content{font-size:13px;margin:10px 12px}
.pop b{font-size:14px}
.pop .pm{color:var(--text-3);font-size:12px;margin-top:2px}
/* cluster */
.cluster{background:var(--accent);border-radius:50%;color:#fff;font-weight:700;font-size:12px;text-align:center;border:2px solid #fff;box-shadow:0 0 0 1px rgba(0,0,0,.06)}
/* demo pins via divIcon */
.pin{width:14px;height:14px;border-radius:50% 50% 50% 0;transform:rotate(-45deg);border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.3)}
/* places view */
.toolbar{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:14px}
.toolbar input,.toolbar select{padding:8px 11px;border:1px solid var(--border);border-radius:9px;font-size:13px;background:#fff}
.toolbar input[type=text]{flex:1;min-width:180px}
.pager{display:flex;align-items:center;gap:10px;justify-content:center;margin-top:14px;font-size:13px}
.pager a{border:1px solid var(--border);padding:5px 12px;border-radius:8px;background:#fff}
.pager .cur{color:var(--text-3)}
/* timeline view */
.tl-day{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:13px 15px;margin-bottom:12px;box-shadow:var(--shadow)}
.tl-head{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:9px}
.tl-head .dt{font-weight:700;font-size:14.5px}
.tl-head .m{color:var(--text-3);font-size:12.5px}
.tl-item{display:flex;gap:10px;align-items:center;padding:6px 0;border-top:1px solid var(--border-2)}
.tl-item .ic{width:26px;height:26px;border-radius:7px;display:inline-flex;align-items:center;justify-content:center;font-size:14px;flex-shrink:0}
.tl-item .tn{font-weight:600}
.tl-item .tm{font-size:12px;color:var(--text-3)}
.tl-item .tr{margin-left:auto;font-size:12px;color:var(--text-2);white-space:nowrap}
.empty{padding:30px 10px;text-align:center;color:var(--text-3)}
.ft{max-width:1080px;margin:0 auto;padding:22px 16px;color:var(--text-3);font-size:12.5px;text-align:center}
.note{font-size:12px;color:var(--text-3);margin-top:10px}
</style>
</head>
<body>
<div class="banner">🧭 <b>演示版</b> · 本页全部为程序生成的示例数据,仅用于展示界面与交互,并非真实足迹记录。地图底图来自公共瓦片服务,需要联网。</div>
<nav class="nav">
<div class="nav-inner">
<a class="brand" href="#home"><span class="avatar">🧭</span><span>足迹地图 · Demo</span></a>
<div class="nav-links">
<a href="#home" data-view="home">主页</a>
<a href="#map" data-view="map">地图</a>
<a href="#places" data-view="places">地点</a>
<a href="#timeline" data-view="timeline">时间线</a>
<a href="#stats" data-view="stats">统计</a>
</div>
<div class="nav-right">
<a class="btn sm" href="https://github.com/tamakyi/rond-with-you" target="_blank" rel="noopener">GitHub</a>
</div>
</div>
</nav>
<main>
<section id="view-home" class="wrap"></section>
<section id="view-map" style="display:none">
<div class="map-page">
<div id="map"></div>
<div class="map-panel" id="map-panel">
<button class="panel-fold" id="panel-fold" type="button" aria-expanded="true"><span>筛选</span><span class="pf-n" id="pf-count">0 个地点</span><span class="pf-c" id="pf-caret" aria-hidden="true">▾</span></button>
<div class="panel-body">
<h3>时间范围</h3>
<div class="chips presets" id="map-presets"></div>
<div class="row" style="margin:10px 0">
<input type="date" id="m-from" style="flex:1">
<input type="date" id="m-to" style="flex:1">
</div>
<h3>地点类型</h3>
<div class="chips" id="map-types" style="margin-bottom:4px"></div>
<details class="adv-fold">
<summary style="cursor:pointer;font-size:12.5px;color:var(--text-2);margin:12px 0 6px">更多筛选 / 图层</summary>
<div class="row" style="margin-bottom:8px">
<select id="m-city" style="flex:1"><option value="">全部城市</option></select>
<select id="m-min" style="flex:1">
<option value="0">不限停留</option><option value="30">≥30 分钟</option>
<option value="60">≥1 小时</option><option value="180">≥3 小时</option>
</select>
</div>
<input type="text" id="m-q" placeholder="关键词(地点/城市)" style="margin-bottom:10px">
<div class="chips" style="margin-bottom:4px">
<label class="chip"><input type="checkbox" id="t-track" style="accent-color:var(--accent)"> 行程轨迹</label>
<label class="chip"><input type="checkbox" id="t-heat" style="accent-color:var(--accent)"> 到访热力</label>
<label class="chip"><input type="checkbox" id="t-city" style="accent-color:var(--accent)"> 城市聚合</label>
</div>
</details>
<div class="map-count muted">当前显示 <b id="pt-count">0</b> 个地点</div>
<div class="row filter-actions">
<button class="btn" id="m-apply" style="flex:1">应用筛选</button>
<button class="btn" id="m-reset">重置</button>
</div>
</div>
</div>
<div class="timeline-bar" id="timeline-bar" hidden>
<button class="tl-play" id="tl-play" type="button" aria-label="播放">▶</button>
<input type="range" id="tl-range" min="0" max="100" value="100" aria-label="时间轴">
<span class="tl-label" id="tl-label"></span>
<button class="tl-reset" id="tl-reset" type="button">全部</button>
</div>
<div class="map-legend" id="map-legend"></div>
</div>
</section>
<section id="view-places" class="wrap" style="display:none"></section>
<section id="view-timeline" class="wrap" style="display:none"></section>
<section id="view-stats" class="wrap" style="display:none"></section>
</main>
<footer class="ft">rond-with-you 演示页 · 纯前端静态页面,数据由浏览器本地生成 · 原项目:Go + PostgreSQL + Leaflet,零外部 CDN</footer>
<script src="vendor/leaflet/leaflet.js"></script>
<script src="vendor/markercluster/leaflet.markercluster.js"></script>
<script src="vendor/heat/leaflet-heat.js"></script>
<script>
"use strict";
/* ============ 确定性随机数(保证每次刷新数据一致) ============ */
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;var t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296;};}
var rnd=mulberry32(20240919);
function rand(a,b){return a+(b-a)*rnd();}
function randint(a,b){return Math.floor(rand(a,b+1));}
function pick(a){return a[Math.floor(rnd()*a.length)];}
function weighted(items,wf){var t=0;for(var i=0;i<items.length;i++)t+=wf(items[i]);var r=rnd()*t;for(var j=0;j<items.length;j++){r-=wf(items[j]);if(r<=0)return items[j];}return items[items.length-1];}
/* ============ 基础数据 ============ */
var CITIES=[
{name:'北京',prov:'北京',lat:39.9042,lng:116.4074,w:4,base:true},
{name:'上海',prov:'上海',lat:31.2304,lng:121.4737,w:4,base:true},
{name:'广州',prov:'广东',lat:23.1291,lng:113.2644,w:3},
{name:'深圳',prov:'广东',lat:22.5431,lng:114.0579,w:3},
{name:'成都',prov:'四川',lat:30.5728,lng:104.0668,w:3},
{name:'杭州',prov:'浙江',lat:30.2741,lng:120.1551,w:2},
{name:'西安',prov:'陕西',lat:34.3416,lng:108.9398,w:2},
{name:'东京',prov:'东京',lat:35.6762,lng:139.6503,w:2,abroad:true},
{name:'新加坡',prov:'新加坡',lat:1.3521,lng:103.8198,w:2,abroad:true}
];
var ACTS=[
{id:1,name:'家',key:'home',icon:'🏠',color:'#94a3b8',w:5},
{id:2,name:'工作',key:'work',icon:'💼',color:'#3b82f6',w:5},
{id:3,name:'餐饮',key:'food',icon:'🍜',color:'#f97316',w:6},
{id:4,name:'咖啡',key:'coffee',icon:'☕',color:'#b45309',w:3},
{id:5,name:'购物',key:'shopping',icon:'🛍️',color:'#ec4899',w:3},
{id:6,name:'景点',key:'sight',icon:'📸',color:'#14b8a6',w:3},
{id:7,name:'公园',key:'park',icon:'🌳',color:'#22c55e',w:2},
{id:8,name:'交通',key:'transit',icon:'🚉',color:'#8b5cf6',w:2},
{id:9,name:'酒店',key:'hotel',icon:'🏨',color:'#6366f1',w:2},
{id:10,name:'医院',key:'hospital',icon:'🏥',color:'#ef4444',w:1}
];
var ACT={}; ACTS.forEach(function(a){ACT[a.id]=a;});
var NAME_POOL={
home:['家','爸妈家'], work:['公司','分部办公室'], food:['巷口面馆','街角火锅','老地方餐馆','夜市大排档','社区食堂'],
coffee:['转角咖啡','河畔咖啡馆','写字楼咖啡','书店咖啡'], shopping:['万象城','百货商场','生活超市','街区小店'],
sight:['古城墙','博物馆','地标广场','老街','观景台'], park:['城市公园','滨江绿道','植物园','湖边步道'],
transit:['火车站','机场','客运站','地铁站'], hotel:['商务酒店','民宿','度假酒店'], hospital:['市第一医院','社区诊所']
};
var TAGS=[{id:1,name:'美食',color:'#f97316'},{id:2,name:'出差',color:'#3b82f6'},{id:3,name:'旅行',color:'#14b8a6'},{id:4,name:'周末',color:'#22c55e'},{id:5,name:'推荐',color:'#ec4899'}];
var MODES=[{name:'步行',icon:'🚶',color:'#22c55e',max:1.5},{name:'骑行',icon:'🚲',color:'#14b8a6',max:5},{name:'地铁',icon:'🚇',color:'#3b82f6',max:25},{name:'公交',icon:'🚌',color:'#8b5cf6',max:25},{name:'驾车',icon:'🚗',color:'#f97316',max:300},{name:'高铁',icon:'🚄',color:'#ec4899',max:1200},{name:'飞机',icon:'✈️',color:'#ef4444',max:99999}];
/* ============ 坐标:WGS-84 → GCJ-02(与高德底图对齐) ============ */
var XPI=3.14159265358979324, XA=6378245.0, XEE=0.00669342162296594323;
function outOfChina(lng,lat){return !(lng>73.66&&lng<135.05&&lat>3.86&&lat<53.55);}
function tLat(x,y){var r=-100+2*x+3*y+0.2*y*y+0.1*x*y+0.2*Math.sqrt(Math.abs(x));r+=(20*Math.sin(6*x*XPI)+20*Math.sin(2*x*XPI))*2/3;r+=(20*Math.sin(y*XPI)+40*Math.sin(y/3*XPI))*2/3;r+=(160*Math.sin(y/12*XPI)+320*Math.sin(y*XPI/30))*2/3;return r;}
function tLng(x,y){var r=300+x+2*y+0.1*x*x+0.1*x*y+0.1*Math.sqrt(Math.abs(x));r+=(20*Math.sin(6*x*XPI)+20*Math.sin(2*x*XPI))*2/3;r+=(20*Math.sin(x*XPI)+40*Math.sin(x/3*XPI))*2/3;r+=(150*Math.sin(x/12*XPI)+300*Math.sin(x/30*XPI))*2/3;return r;}
function wgs2gcj(lng,lat){if(outOfChina(lng,lat))return [lng,lat];var dLat=tLat(lng-105,lat-35),dLng=tLng(lng-105,lat-35);var rl=lat/180*XPI,m=Math.sin(rl);m=1-XEE*m*m;var sm=Math.sqrt(m);dLat=(dLat*180)/((XA*(1-XEE))/(m*sm)*XPI);dLng=(dLng*180)/(XA/sm*Math.cos(rl)*XPI);return [lng+dLng,lat+dLat];}
/* ============ 生成地点 ============ */
var PLACES=[], pid=1, placeById={}, usedName={};
CITIES.forEach(function(c){
var n=randint(9,15);
for(var i=0;i<n;i++){
var act = c.base ? weighted(ACTS,function(a){return a.w;}) : weighted(ACTS.filter(function(a){return a.id>2;}),function(a){return a.w;});
var pool=NAME_POOL[act.key];
var name=pick(pool)+(rnd()<0.4?(' '+(randint(1,9))+'号'):'');
if(!usedName[c.name]) usedName[c.name]={};
var base=name,k=2;
while(usedName[c.name][name]){name=base+' '+(k++);} // 同城同名去重
usedName[c.name][name]=1;
var g=wgs2gcj(c.lng+rand(-0.045,0.045), c.lat+rand(-0.035,0.035));
var p={id:pid++,name:name,city:c.name,prov:c.prov,lat:g[1],lng:g[0],actId:act.id,visits:[]};
PLACES.push(p); placeById[p.id]=p;
}
});
/* ============ 生成到访 ============ */
var START=new Date('2024-01-01T00:00:00').getTime();
var END=new Date('2025-12-31T23:59:59').getTime();
var VISIT_COUNT=760, visits=[];
var raw=[], curCity=CITIES[0];
for(var i=0;i<VISIT_COUNT;i++){
if(i>0 && rnd()<0.05) curCity=weighted(CITIES,function(c){return c.w;}); // 偶尔出行换城市,其余时间同城活动
var cityPlaces=PLACES.filter(function(p){return p.city===curCity.name;});
var place=weighted(cityPlaces,function(p){return ACT[p.actId].w;});
var act=ACT[place.actId];
var dmin = act.id===1?rand(180,600): act.id===2?rand(240,540): act.id===8?rand(5,90): rand(20,180);
var tags=[];
if(rnd()<0.28) tags.push(pick(TAGS).id);
if(rnd()<0.12) tags.push(pick(TAGS).id);
raw.push({place:place,dmin:dmin,gap:rand(150,2200),tags:tags});
}
// 按时间顺序铺到 2024–2025:相邻两次到访间隔为 gap 分钟,整体归一化铺满两年
var sumD=0,sumG=0; raw.forEach(function(r){sumD+=r.dmin;sumG+=r.gap;});
var spanMin=(END-START)/60000, gscale=sumG>0?(spanMin-sumD)/sumG:1; if(gscale<=0)gscale=0.01;
var tcur=START;
raw.forEach(function(r){
var arrival=tcur, departure=arrival+r.dmin*60000;
var v={placeId:r.place.id,arrival:arrival,departure:departure,dur:r.dmin,tags:r.tags};
visits.push(v); r.place.visits.push(v);
tcur=departure+r.gap*gscale*60000;
});
/* ============ 工具 ============ */
function hav(la1,lo1,la2,lo2){var R=6371,dLa=(la2-la1)*Math.PI/180,dLo=(lo2-lo1)*Math.PI/180,a=Math.sin(dLa/2)**2+Math.cos(la1*Math.PI/180)*Math.cos(la2*Math.PI/180)*Math.sin(dLo/2)**2;return R*2*Math.atan2(Math.sqrt(a),Math.sqrt(1-a));}
function num(n){return (n==null?0:n).toLocaleString('zh-CN');}
function dur(min){if(min==null)return '—';min=Math.round(min);if(min<60)return min+' 分钟';var h=Math.floor(min/60),m=min%60;if(h<24)return (h?h+' 小时':'')+(m?m+' 分':'');var d=Math.floor(h/24),hh=h%24;return d+' 天'+(hh?hh+' 小时':'');}
function km(v){return (v==null?0:v).toLocaleString('zh-CN',{maximumFractionDigits:0});}
function pct(a,b){return b?Math.max(2,Math.round(a/b*100)):0;}
function since(ts){var d=Math.floor((Date.now()-ts)/86400000);if(d<=0)return '今天';if(d<30)return d+' 天前';if(d<365)return Math.floor(d/30)+' 个月前';return Math.floor(d/365)+' 年前';}
function fdate(ts){var d=new Date(ts);return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0');}
function fhm(ts){var d=new Date(ts);return String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0');}
function tagName(id){for(var i=0;i<TAGS.length;i++)if(TAGS[i].id===id)return TAGS[i].name;return '';}
/* ============ 聚合统计 ============ */
var S={};
(function(){
var cities={}, provs={}, types={}, days={}, hourly=new Array(24).fill(0), weekday=new Array(7).fill(0), dwellTotal=0, distance=0, firstAt=Infinity, lastAt=0;
var cityVisit={}, cityPlace={};
visits.forEach(function(v){
var p=placeById[v.placeId];
cities[p.city]=(cities[p.city]||0)+1;
if(!cityPlace[p.city]) cityPlace[p.city]={};
cityPlace[p.city][p.id]=1;
var pr=provs[p.prov]||(provs[p.prov]={cities:{},places:{},count:0});
pr.cities[p.city]=1; pr.places[p.id]=1; pr.count++;
var t=types[p.actId]||(types[p.actId]={count:0,dwell:0});
t.count++; t.dwell+=v.dur;
var d=new Date(v.arrival); var key=fdate(v.arrival);
days[key]=(days[key]||0)+1;
hourly[d.getHours()]++; weekday[d.getDay()]++;
dwellTotal+=v.dur; firstAt=Math.min(firstAt,v.arrival); lastAt=Math.max(lastAt,v.departure);
});
// 出行里程 = 相邻到访地点不同的直线距离之和
for(var i=1;i<visits.length;i++){var a=placeById[visits[i-1].placeId],b=placeById[visits[i].placeId];if(a.id!==b.id)distance+=hav(a.lat,a.lng,b.lat,b.lng);}
S.places=PLACES.length;
S.cities=Object.keys(cities).length;
S.provinces=Object.keys(provs).length;
S.days=Object.keys(days).length;
S.visits=visits.length;
S.dwell=dwellTotal; S.distance=distance; S.firstAt=firstAt; S.lastAt=lastAt;
S.types=ACTS.map(function(a){var t=types[a.id]||{count:0,dwell:0};return {id:a.id,name:a.name,icon:a.icon,color:a.color,count:t.count,dwell:t.dwell};}).filter(function(x){return x.count>0;}).sort(function(a,b){return b.count-a.count;});
S.cityStats=Object.keys(cities).map(function(c){return {city:c,count:cities[c],places:Object.keys(cityPlace[c]).length};}).sort(function(a,b){return b.count-a.count;});
S.provStats=Object.keys(provs).map(function(p){var x=provs[p];return {prov:p,cities:Object.keys(x.cities).length,places:Object.keys(x.places).length,count:x.count};}).sort(function(a,b){return b.count-a.count;});
// 月度(最近 24 个月)
var months=[]; var cur=new Date(START); cur.setDate(1);
var endM=new Date(END); endM.setDate(1);
var mcount={};
visits.forEach(function(v){var d=new Date(v.arrival);var k=d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0');mcount[k]=(mcount[k]||0)+1;});
while(cur<=endM){var key=cur.getFullYear()+'-'+String(cur.getMonth()+1).padStart(2,'0');months.push({key:key,label:(cur.getMonth()+1)+'月',count:mcount[key]||0});cur.setMonth(cur.getMonth()+1);}
var mmax=Math.max.apply(null,months.map(function(m){return m.count;}));
months.forEach(function(m){m.pct=pct(m.count,mmax);});
S.monthly=months;
// 活跃日历(最近 ~53 周)
var calStart=END-371*86400000; calStart=calStart-calStart%86400000; // 对齐到天
var calDays={}; visits.forEach(function(v){var k=fdate(v.arrival);calDays[k]=(calDays[k]||0)+1;});
var weeks=[], dayMax=0;
for(var t=calStart;t<=END;t+=86400000){var dt=new Date(t);dayMax=Math.max(dayMax,calDays[fdate(t)]||0);}
// 对齐到周日
var firstDow=new Date(calStart).getDay(); var t0=calStart-firstDow*86400000;
var curWeek=null, monthsRow=[];
for(var t2=t0;t2<=END+6*86400000;t2+=86400000){
var wd=new Date(t2).getDay();
if(wd===0){curWeek=[];weeks.push(curWeek);}
var c=calDays[fdate(t2)]||0;
var lvl=0; if(c>0){lvl=c<dayMax*0.25?1:c<dayMax*0.5?2:c<dayMax*0.75?3:4;}
curWeek.push({date:t2,count:c,level:lvl,title:fdate(t2)+':'+(c||'无')+' 次到访'});
if(wd===0){var md=new Date(t2);monthsRow.push({i:weeks.length-1,label:md.getMonth()===0?md.getFullYear()+'':(md.getMonth()+1)+'月'});}
}
S.calendar={weeks:weeks,dayMax:dayMax,months:monthsRow};
var hmax=Math.max.apply(null,hourly);
S.hourly=hourly.map(function(c,h){return {h:h,count:c,pct:pct(c,hmax)};});
var wmax=Math.max.apply(null,weekday);
var wlab=['周日','周一','周二','周三','周四','周五','周六'];
S.weekday=weekday.map(function(c,idx){return {label:wlab[idx],count:c,pct:pct(c,wmax)};});
// 出行方式(相邻不同地点的位移段)
var segs=[];
for(var k=1;k<visits.length;k++){var pa=placeById[visits[k-1].placeId],pb=placeById[visits[k].placeId];if(pa.id===pb.id)continue;var gap=(visits[k].arrival-visits[k-1].departure)/60000;if(gap<3||gap>600)continue;var dist=hav(pa.lat,pa.lng,pb.lat,pb.lng);var mi=dist<1.5?0:dist<5?1:dist<25?(rnd()<0.5?2:3):dist<300?4:dist<1200?5:6;segs.push({a:pa,b:pb,dist:dist,min:gap,mode:mi});}
var mgroup={}; segs.forEach(function(s){var g=mgroup[s.mode]||(mgroup[s.mode]={count:0,dist:0,dwell:0});g.count++;g.dist+=s.dist;g.dwell+=s.min;});
S.transports=MODES.map(function(m,i){var g=mgroup[i]||{count:0,dist:0,dwell:0};return {name:m.name,icon:m.icon,color:m.color,count:g.count,dist:g.dist,dwell:g.dwell};}).filter(function(x){return x.count>0;}).sort(function(a,b){return b.count-a.count;});
S.segs=segs;
// 标签
var tc={};
visits.forEach(function(v){v.tags.forEach(function(id){tc[id]=(tc[id]||0)+1;});});
S.tags=TAGS.map(function(t){return {id:t.id,name:t.name,color:t.color,count:tc[t.id]||0};}).filter(function(x){return x.count>0;}).sort(function(a,b){return b.count-a.count;});
// 最近足迹
S.recent=visits.slice(-12).reverse().map(function(v){var p=placeById[v.placeId];return {name:p.name,city:p.city,act:ACT[p.actId],dur:v.dur,arrival:v.arrival,tags:v.tags};});
// 天气(确定性合成)
var CONDS=[{c:'晴',i:'☀️',at:23},{c:'多云',i:'⛅',at:19},{c:'阴',i:'☁️',at:16},{c:'小雨',i:'🌦️',at:14},{c:'雷阵雨',i:'⛈️',at:25},{c:'雪',i:'❄️',at:0},{c:'雾',i:'🌫️',at:10}];
S.weather=CONDS.map(function(x){var h=Math.round(rand(180,2600));return {c:x.c,i:x.i,count:h,avg:x.at+Math.round(rand(-4,4)),min:x.at-Math.round(rand(3,8)),max:x.at+Math.round(rand(3,8))};}).sort(function(a,b){return b.count-a.count;});
// 世界迷雾(mock)
var cells=Math.round(rand(480000,620000));
var litRate=rand(0.58,0.78);
var litPlaces=Math.round(S.places*litRate);
S.fog={blocks:Math.round(cells/64),cells:cells,areaKm2:+(cells*0.0000912).toFixed(1),litPlaces:litPlaces,places:S.places,litRate:Math.round(litRate*100)};
S.fogCities=S.cityStats.map(function(c){var total=c.places;var lit=Math.round(total*rand(0.45,0.92));return {name:c.city,lit:lit,total:total};}).sort(function(a,b){return b.lit-a.lit;});
})();
/* ============ 渲染:主页 ============ */
function kpi(v,u,k){return '<div class="kpi"><div class="v">'+v+(u?'<small>'+u+'</small>':'')+'</div><div class="k">'+k+'</div></div>';}
function barRow(nm,fill,w,vv,extra){return '<div class="bar-row"'+(extra||'')+'><div class="nm" title="'+nm+'">'+nm+'</div><div class="bar-track"><div class="bar-fill" style="width:'+w+'%;'+(fill?('background:'+fill):'')+'"></div></div><div class="vv">'+vv+'</div></div>';}
function renderHome(){
var h='';
h+='<div class="page-head"><div><h1>🧭 我的足迹</h1><div class="sub">示例数据 · 覆盖 '+S.cities+' 座城市 · '+fdate(S.firstAt)+' 起记录</div></div>'+
'<div class="row"><a class="btn" href="#map">全屏地图</a><a class="btn" href="#stats">数据统计</a></div></div>';
h+='<div class="kpis" style="margin-bottom:16px">';
h+=kpi(num(S.places),'个','去过的地点');
h+=kpi(num(S.cities),'座','到过的城市');
h+=kpi(num(S.provinces),'个','覆盖省份');
h+=kpi(num(S.days),'天','有记录的日子');
h+=kpi(num(S.visits),'次','累计到访');
h+=kpi(dur(S.dwell),'','累计停留');
h+=kpi(km(S.distance),'km','出行里程');
h+=kpi(since(S.firstAt),'','开始记录于');
h+='</div>';
h+='<div class="card"><h2>足迹地图</h2><div class="card-sub">共 '+num(S.places)+' 个地点(示例)。点击进入全屏模式查看筛选与时间轴。</div><div id="home-map" class="map-preview"></div></div>';
h+='<div class="grid g2">';
h+='<div class="card"><h2>去过的类型</h2><div class="card-sub">按到访次数排序</div><div class="bars">';
var maxA=S.types[0]?S.types[0].count:1;
S.types.forEach(function(t){h+=barRow(t.icon+' '+t.name,t.color,pct(t.count,maxA),num(t.count)+' 次');});
h+='</div></div>';
h+='<div class="card"><h2>城市足迹</h2><div class="card-sub">按到访次数排序</div><div class="bars">';
var maxC=S.cityStats[0]?S.cityStats[0].count:1;
S.cityStats.forEach(function(c){h+=barRow(c.city,'',pct(c.count,maxC),num(c.count)+' 次');});
h+='</div><div class="note">共覆盖 '+num(S.cities)+' 座城市、'+num(S.provinces)+' 个省级行政区。</div></div>';
h+='</div>';
// 月度活跃度
h+='<div class="card"><h2>月度活跃度</h2><div class="card-sub">每月到访次数,最高 '+num(Math.max.apply(null,S.monthly.map(function(m){return m.count;})))+' 次</div><div class="cols">';
S.monthly.forEach(function(m){h+='<div class="col" style="height:'+m.pct+'%" title="'+m.key+':'+m.count+' 次"></div>';});
h+='</div><div class="cols-x">';
S.monthly.forEach(function(m){h+='<span>'+(m.label.replace('月',''))+'</span>';});
h+='</div></div>';
// 活跃日历
var cal=S.calendar;
var ml=[];
cal.months.forEach(function(mo){ml[mo.i]='<span class="ml">'+mo.label+'</span>';});
var mrow=''; for(var wi=0;wi<cal.weeks.length;wi++){mrow+=(ml[wi]||'<span></span>');}
h+='<div class="card"><h2>活跃日历</h2><div class="card-sub">每一列是一周,颜色越深记录越多</div><div class="heat">';
cal.weeks.forEach(function(w){h+='<div class="wk">';w.forEach(function(d){h+='<div class="d '+(d.level?'l'+d.level:'')+'" title="'+d.title+'"></div>';});h+='</div>';});
h+='</div><div class="heat-months">'+mrow+'</div><div class="heat-legend">少 <span class="d"></span><span class="d l1"></span><span class="d l2"></span><span class="d l3"></span><span class="d l4"></span> 多</div></div>';
h+='<div class="grid g2">';
h+='<div class="card"><h2>出行方式</h2><div class="card-sub">按次数排序</div><div class="bars">';
var maxT=S.transports[0]?S.transports[0].count:1;
S.transports.forEach(function(t){h+=barRow(t.icon+' '+t.name,t.color,pct(t.count,maxT),num(t.count)+' 次');});
h+='</div></div>';
h+='<div class="card"><h2>我的标签</h2><div class="card-sub">记录时打上的自定义标签</div><div class="chips">';
S.tags.forEach(function(t){h+='<span class="chip"><span class="dot" style="background:'+t.color+'"></span>'+t.name+' <span class="muted">'+num(t.count)+'</span></span>';});
h+='</div></div>';
h+='</div>';
// 天气
h+='<div class="card"><h2>天气足迹</h2><div class="card-sub">记录期间经历过的天气(示例)</div><div class="tw"><table class="tb"><thead><tr><th>天气</th><th class="num">记录小时数</th><th class="num">平均气温</th><th class="num">范围</th></tr></thead><tbody>';
S.weather.forEach(function(w){h+='<tr><td>'+w.i+' '+w.c+'</td><td class="num">'+num(w.count)+'</td><td class="num">'+w.avg+' ℃</td><td class="num">'+w.min+' ~ '+w.max+' ℃</td></tr>';});
h+='</tbody></table></div></div>';
// 最近足迹
h+='<div class="card"><h2>最近足迹</h2><div class="card-sub">最新 '+S.recent.length+' 条到访记录</div><div class="list">';
S.recent.forEach(function(r){var tags=r.tags.map(function(id){return '<span class="tag">'+tagName(id)+'</span>';}).join(' ');
h+='<div class="item"><div class="ic" style="background:'+r.act.color+'22">'+r.act.icon+'</div><div><div class="t">'+r.name+'</div><div class="m">'+fdate(r.arrival)+' · '+fhm(r.arrival)+' · '+r.city+' · '+r.act.name+(tags?' · '+tags:'')+'</div></div><div class="r">'+dur(r.dur)+'<br><span class="muted">'+since(r.arrival)+'</span></div></div>';});
h+='</div><div style="margin-top:12px"><a class="btn sm" href="#timeline">查看完整时间线 →</a></div></div>';
document.getElementById('view-home').innerHTML=h;
}
/* ============ 渲染:地点库 ============ */
var placeState={q:'',sort:'visits',page:1,pageSize:12};
function renderPlaces(){
var q=placeState.q.trim().toLowerCase();
var rows=PLACES.map(function(p){return {p:p,visits:p.visits.length,dwell:p.visits.reduce(function(s,v){return s+v.dur;},0),last:p.visits.length?Math.max.apply(null,p.visits.map(function(v){return v.arrival;})):0};});
if(q) rows=rows.filter(function(r){return r.p.name.toLowerCase().indexOf(q)>=0||r.p.city.toLowerCase().indexOf(q)>=0||r.p.prov.toLowerCase().indexOf(q)>=0;});
rows.sort(function(a,b){if(placeState.sort==='name')return a.p.name.localeCompare(b.p.name,'zh');if(placeState.sort==='city')return a.p.city.localeCompare(b.p.city,'zh')||b.visits-a.visits;if(placeState.sort==='last')return b.last-a.last;return b.visits-a.visits;});
var total=rows.length, pages=Math.max(1,Math.ceil(total/placeState.pageSize));
if(placeState.page>pages)placeState.page=pages;
var start=(placeState.page-1)*placeState.pageSize;
var slice=rows.slice(start,start+placeState.pageSize);
var h='<div class="page-head"><div><h1>地点库</h1><div class="sub">共 '+num(total)+' 个地点</div></div></div>';
h+='<div class="toolbar"><input type="text" id="p-q" placeholder="搜索地点 / 城市 / 省份" value="'+placeState.q+'">'+
'<select id="p-sort"><option value="visits">按到访次数</option><option value="city">按城市</option><option value="name">按名称</option><option value="last">按最近到访</option></select>'+
'<a class="btn" href="#map">在地图上查看</a></div>';
h+='<div class="card" style="padding:0;overflow:hidden"><div class="tw"><table class="tb"><thead><tr><th>地点</th><th>城市</th><th>类型</th><th class="num">到访</th><th class="num">累计停留</th><th class="num">最近到访</th></tr></thead><tbody>';
slice.forEach(function(r){var a=ACT[r.p.actId];
h+='<tr><td>'+r.p.name+'</td><td>'+r.p.city+'</td><td><span class="dot" style="background:'+a.color+'"></span> '+a.name+'</td><td class="num">'+num(r.visits)+'</td><td class="num">'+dur(r.dwell)+'</td><td class="num">'+fdate(r.last)+'</td></tr>';
});
h+='</tbody></table></div></div>';
h+='<div class="pager"><a href="#" id="p-prev">上一页</a><span class="cur">第 '+placeState.page+' / '+pages+' 页</span><a href="#" id="p-next">下一页</a></div>';
var el=document.getElementById('view-places'); el.innerHTML=h;
document.getElementById('p-q').addEventListener('input',function(e){placeState.q=e.target.value;placeState.page=1;renderPlaces();});
document.getElementById('p-sort').addEventListener('change',function(e){placeState.sort=e.target.value;placeState.page=1;renderPlaces();});
document.getElementById('p-prev').addEventListener('click',function(e){e.preventDefault();if(placeState.page>1){placeState.page--;renderPlaces();}});
document.getElementById('p-next').addEventListener('click',function(e){e.preventDefault();if(placeState.page<pages){placeState.page++;renderPlaces();}});
}
/* ============ 渲染:时间线 ============ */
function renderTimeline(){
var recent=visits.slice(-260).reverse();
var groups={}, order=[];
recent.forEach(function(v){var k=fdate(v.arrival);if(!groups[k]){groups[k]={date:v.arrival,count:0,dwell:0,items:[]};order.push(k);}var g=groups[k];g.count++;g.dwell+=v.dur;var p=placeById[v.placeId];g.items.push({p:p,v:v});});
var h='<div class="page-head"><div><h1>时间线</h1><div class="sub">按天分组的到访流水(最近 260 条)</div></div></div>';
order.forEach(function(k){var g=groups[k];
h+='<div class="tl-day"><div class="tl-head"><span class="dt">'+k+' '+['周日','周一','周二','周三','周四','周五','周六'][new Date(g.date).getDay()]+'</span><span class="m">'+g.count+' 次 · 累计 '+dur(g.dwell)+'</span></div>';
g.items.forEach(function(it){var a=ACT[it.p.actId];var tags=it.v.tags.map(function(id){return '<span class="tag">'+tagName(id)+'</span>';}).join(' ');
h+='<div class="tl-item"><div class="ic" style="background:'+a.color+'22">'+a.icon+'</div><div><div class="tn">'+it.p.name+'</div><div class="tm">'+fhm(it.v.arrival)+' · '+it.p.city+' · '+a.name+(tags?' · '+tags:'')+'</div></div><div class="tr">'+dur(it.v.dur)+'</div></div>';
});
h+='</div>';
});
document.getElementById('view-timeline').innerHTML=h;
}
/* ============ 渲染:统计 ============ */
function renderStats(){
var h='<div class="page-head"><div><h1>数据统计</h1><div class="sub">示例数据 · 2024–2025</div></div></div>';
h+='<div class="kpis" style="margin-bottom:16px">';
h+=kpi(num(S.visits),'次','到访记录');
h+=kpi(num(S.places),'个','不同地点');
h+=kpi(dur(S.dwell),'','累计停留');
h+=kpi(km(S.distance),'km','出行里程');
h+='</div>';
// 月度分布
var mmax=Math.max.apply(null,S.monthly.map(function(m){return m.count;}));
h+='<div class="card"><h2>月度分布</h2><div class="card-sub">每月到访次数,最高 '+num(mmax)+' 次</div><div class="cols">';
S.monthly.forEach(function(m){h+='<div class="col" style="height:'+m.pct+'%" title="'+m.key+':'+m.count+' 次"></div>';});
h+='</div><div class="cols-x">';S.monthly.forEach(function(m){h+='<span>'+(m.label.replace('月',''))+'</span>';});h+='</div></div>';
h+='<div class="grid g2">';
h+='<div class="card"><h2>类型分布</h2><div class="card-sub">按到访次数排序</div><div class="bars">';
var maxA=S.types[0].count;
S.types.forEach(function(t){h+=barRow(t.icon+' '+t.name,t.color,pct(t.count,maxA),num(t.count)+' 次 · '+dur(t.dwell));});
h+='</div></div>';
h+='<div class="card"><h2>一天中的分布</h2><div class="card-sub">按到访开始时间统计</div><div class="cols" style="height:130px">';
S.hourly.forEach(function(x){h+='<div class="col" style="height:'+x.pct+'%" title="'+x.h+' 时:'+x.count+' 次"></div>';});
h+='</div><div class="cols-x">';S.hourly.forEach(function(x){h+='<span>'+(x.h===0?'0':x.h===6?'6':x.h===12?'12':x.h===18?'18':' ')+'</span>';});h+='</div></div>';
h+='</div>';
h+='<div class="grid">';
h+='<div class="card"><h2>城市排行</h2><div class="card-sub">到访次数</div><div class="bars">';
var maxC=S.cityStats[0].count;
S.cityStats.forEach(function(c){h+=barRow(c.city,'',pct(c.count,maxC),num(c.count)+' 次 · '+num(c.places)+' 地点');});
h+='</div></div>';
h+='<div class="card"><h2>省级行政区</h2><div class="card-sub">覆盖情况</div><div class="tw"><table class="tb"><thead><tr><th>省份</th><th class="num">城市</th><th class="num">地点</th><th class="num">到访</th></tr></thead><tbody>';
S.provStats.forEach(function(p){h+='<tr><td>'+p.prov+'</td><td class="num">'+num(p.cities)+'</td><td class="num">'+num(p.places)+'</td><td class="num">'+num(p.count)+'</td></tr>';});
h+='</tbody></table></div></div>';
h+='</div>';
h+='<div class="grid">';
h+='<div class="card"><h2>星期分布</h2><div class="card-sub">一周中的到访规律</div><div class="bars">';
var maxW=S.weekday[0].count;
S.weekday.forEach(function(w){h+=barRow(w.label,'',pct(w.count,maxW),num(w.count)+' 次');});
h+='</div></div>';
h+='<div class="card"><h2>出行方式</h2><div class="card-sub">次数与估算里程</div><div class="tw"><table class="tb"><thead><tr><th>方式</th><th class="num">次数</th><th class="num">里程</th><th class="num">在途时间</th></tr></thead><tbody>';
S.transports.forEach(function(t){h+='<tr><td><span class="dot" style="display:inline-block;background:'+t.color+'"></span> '+t.icon+' '+t.name+'</td><td class="num">'+num(t.count)+'</td><td class="num">'+km(t.dist)+' km</td><td class="num">'+dur(t.dwell)+'</td></tr>';});
h+='</tbody></table></div></div>';
h+='</div>';
h+='<div class="grid">';
h+='<div class="card"><h2>标签统计</h2><div class="card-sub">自定义标签的使用情况</div><div class="bars">';
var maxTag=S.tags[0]?S.tags[0].count:1;
S.tags.forEach(function(t){h+=barRow('<span class="dot" style="display:inline-block;background:'+t.color+'"></span> '+t.name,'',pct(t.count,maxTag),num(t.count)+' 次');});
h+='</div></div>';
h+='<div class="card"><h2>天气与气温</h2><div class="card-sub">记录期间经历的小时级天气</div><div class="tw"><table class="tb"><thead><tr><th>天气</th><th class="num">小时数</th><th class="num">平均气温</th></tr></thead><tbody>';
S.weather.forEach(function(w){h+='<tr><td>'+w.i+' '+w.c+'</td><td class="num">'+num(w.count)+'</td><td class="num">'+w.avg+' ℃</td></tr>';});
h+='</tbody></table></div></div>';
h+='</div>';
h+='<div class="card"><h2>世界迷雾覆盖</h2><div class="card-sub">迷雾快照 '+num(S.fog.blocks)+' 块 / '+num(S.fog.cells)+' 格,估算已探索 <b>'+S.fog.areaKm2+'</b> km²;地点落在已探索格内的比例即「点亮率」</div>';
h+='<div class="kpis" style="margin-bottom:12px">';
h+=kpi(S.fog.areaKm2,'km²','已探索面积');
h+=kpi(S.fog.litRate,'%','地点点亮率');
h+=kpi(S.fog.litPlaces,'/'+num(S.fog.places),'点亮地点');
h+='</div><div class="bars">';
S.fogCities.forEach(function(c){h+=barRow(c.name,'',pct(c.lit,c.total),c.lit+'/'+c.total);});
h+='</div></div>';
document.getElementById('view-stats').innerHTML=h;
}
/* ============ 地图 ============ */
var BASEMAPS={
street:{url:'https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}',attr:'© 高德地图 AutoNavi',name:'街道',sub:'1234'},
roadnet:{url:'https://wprd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=7&x={x}&y={y}&z={z}',attr:'© 高德地图 AutoNavi',name:'纯路网',sub:'1234'},
satellite:{url:'https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}',attr:'© 高德地图 AutoNavi',name:'卫星',sub:'1234'},
dark:{url:'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile/{z}/{y}/{x}',attr:'Tiles © Esri',name:'暗色',sub:'abcd'}
};
var PREVIEW=null, FULLMAP=null, FULL_INIT=false;
var cluster=null, heatLayer=null, cityLayer=null, trackLayer=null;
var allLatLngs=[];
function pinIcon(color){return L.divIcon({className:'',html:'<div class="pin" style="background:'+color+'"></div>',iconSize:[14,14],iconAnchor:[7,14]});}
function clusterIcon(c){var n=c.getChildCount();var s=n<10?34:n<50?42:n<100?50:58;return L.divIcon({html:'<div style="width:'+s+'px;height:'+s+'px;line-height:'+s+'px">'+n+'</div>',className:'cluster',iconSize:[s,s]});}
function makeMarker(p){
var a=ACT[p.actId];
var m=L.marker([p.lat,p.lng],{icon:pinIcon(a.color)});
m.bindPopup('<div class="pop"><b>'+p.name+'</b><div class="pm">'+p.city+' · '+a.icon+' '+a.name+'</div><div class="pm">'+num(p.visits.length)+' 次到访 · 最近 '+fdate(p.visits.length?Math.max.apply(null,p.visits.map(function(v){return v.arrival;})):0)+'</div></div>');
return m;
}
function ensurePreview(){
if(PREVIEW) return;
PREVIEW=L.map('home-map',{scrollWheelZoom:false,zoomControl:true,dragging:true});
L.tileLayer(BASEMAPS.street.url,{attribution:BASEMAPS.street.attr,subdomains:'1234',maxZoom:19}).addTo(PREVIEW);
var c=L.markerClusterGroup({showCoverageOnHover:false,maxClusterRadius:40,iconCreateFunction:clusterIcon});
PLACES.forEach(function(p){c.addLayer(makeMarker(p));});
PREVIEW.addLayer(c);
PREVIEW.fitBounds(boundsOf(PLACES),{padding:[18,18]});
PREVIEW.on('click',function(){location.hash='#map';});
}
function boundsOf(places){var b=[];places.forEach(function(p){b.push([p.lat,p.lng]);});return b;}
/* 地图筛选状态 */
var mapState={from:'',to:'',acts:new Set(),city:'',min:0,q:'',track:false,heat:false,cityAgg:false};
function filteredPlaces(){
var from=mapState.from?new Date(mapState.from).getTime():-Infinity;
var to=mapState.to?new Date(mapState.to).getTime()+86400000-1:Infinity;
var q=mapState.q.trim().toLowerCase();
return PLACES.filter(function(p){
if(mapState.acts.size && !mapState.acts.has(p.actId)) return false;
if(mapState.city && p.city!==mapState.city) return false;
if(q && !(p.name.toLowerCase().indexOf(q)>=0||p.city.toLowerCase().indexOf(q)>=0||p.prov.toLowerCase().indexOf(q)>=0)) return false;
var ok=false;
for(var i=0;i<p.visits.length;i++){var v=p.visits[i];if(v.arrival>=from&&v.arrival<=to&&v.dur>=mapState.min){ok=true;break;}}
return ok;
});
}
function rebuildMap(keepView){
if(!FULLMAP) return;
var list=filteredPlaces();
if(cluster){FULLMAP.removeLayer(cluster);}
cluster=L.markerClusterGroup({showCoverageOnHover:false,maxClusterRadius:45,iconCreateFunction:clusterIcon});
list.forEach(function(p){cluster.addLayer(makeMarker(p));});
FULLMAP.addLayer(cluster);
document.getElementById('pt-count').textContent=list.length;
var pfc=document.getElementById('pf-count'); if(pfc)pfc.textContent=list.length+' 个地点';
if(list.length && !keepView) FULLMAP.fitBounds(boundsOf(list),{padding:[40,40],maxZoom:12});
if(mapState.heat) drawHeat(list); else if(heatLayer){FULLMAP.removeLayer(heatLayer);heatLayer=null;}
if(mapState.cityAgg) drawCityAgg(); else if(cityLayer){FULLMAP.removeLayer(cityLayer);cityLayer=null;}
}
function drawHeat(list){
if(heatLayer)FULLMAP.removeLayer(heatLayer);
var pts=[];list.forEach(function(p){var w=Math.min(1,p.visits.length/8+0.2);pts.push([p.lat,p.lng,w]);});
heatLayer=L.heatLayer(pts,{radius:26,blur:18,maxZoom:13,gradient:{0.2:'#1c9d57',0.5:'#f7b32b',0.8:'#ef4444',1.0:'#b91c1c'}});
heatLayer.addTo(FULLMAP);
}
function drawCityAgg(){
if(cityLayer)FULLMAP.removeLayer(cityLayer);
cityLayer=L.layerGroup();
var maxC=S.cityStats[0].count;
S.cityStats.forEach(function(cs){
var c=CITIES.filter(function(x){return x.name===cs.city;})[0];if(!c)return;
var r=4000+ (cs.count/maxC)*26000;
L.circleMarker([c.lat,c.lng],{radius:Math.sqrt(cs.count)*2.6+6,color:'#1c9d57',fillColor:'#1c9d57',fillOpacity:.18,weight:2}).bindPopup('<b>'+cs.city+'</b><br>'+num(cs.count)+' 次到访 · '+num(cs.places)+' 个地点').addTo(cityLayer);
});
cityLayer.addTo(FULLMAP);
}
function drawTrack(){
if(trackLayer)FULLMAP.removeLayer(trackLayer);
if(!mapState.track){trackLayer=null;return;}
trackLayer=L.layerGroup();
S.segs.forEach(function(s){var m=MODES[s.mode];L.polyline([[s.a.lat,s.a.lng],[s.b.lat,s.b.lng]],{color:m.color,weight:2.5,opacity:.7}).addTo(trackLayer);});
trackLayer.addTo(FULLMAP);
}
function ensureMap(){
if(FULL_INIT) return; FULL_INIT=true;
FULLMAP=L.map('map',{zoomControl:true});
var baseCtl=L.control.layers({},{},{position:'topright'});
var layers={};
Object.keys(BASEMAPS).forEach(function(k){(function(k){var l=L.tileLayer(BASEMAPS[k].url,{attribution:BASEMAPS[k].attr,subdomains:BASEMAPS[k].sub,maxZoom:19});layers[BASEMAPS[k].name]=l;})(k);});
baseCtl.addBaseLayer(layers['街道'],'街道');
baseCtl.addBaseLayer(layers['纯路网'],'纯路网');
baseCtl.addBaseLayer(layers['卫星'],'卫星');
baseCtl.addBaseLayer(layers['暗色'],'暗色');
baseCtl.addTo(FULLMAP);
layers['街道'].addTo(FULLMAP);
// 预设
var presets=[{l:'近 30 天',f:daysAgo(30)},{l:'近 90 天',f:daysAgo(90)},{l:'今年',f:new Date(new Date().getFullYear()+'-01-01')},{l:'全部',f:null}];
var ph='';presets.forEach(function(p,idx){ph+='<span class="chip'+(idx===presets.length-1?' on':'')+'" data-from="'+(p.f?fdate(p.f):'')+'">'+p.l+'</span>';});
document.getElementById('map-presets').innerHTML=ph;
document.getElementById('map-presets').querySelectorAll('.chip').forEach(function(ch){ch.addEventListener('click',function(){document.getElementById('map-presets').querySelectorAll('.chip').forEach(function(x){x.classList.remove('on');});ch.classList.add('on');mapState.from=ch.dataset.from;mapState.to='';document.getElementById('m-from').value='';document.getElementById('m-to').value='';rebuildMap();});});
// 类型 chips
var th='';S.types.forEach(function(t){th+='<span class="chip" data-act="'+t.id+'"><span class="dot" style="background:'+t.color+'"></span>'+t.icon+' '+t.name+'</span>';});
document.getElementById('map-types').innerHTML=th;
document.getElementById('map-types').querySelectorAll('.chip').forEach(function(ch){ch.addEventListener('click',function(){ch.classList.toggle('on');if(ch.classList.contains('on'))mapState.acts.add(+ch.dataset.act);else mapState.acts.delete(+ch.dataset.act);rebuildMap();});});
// 城市下拉
var citySel=document.getElementById('m-city');
S.cityStats.forEach(function(c){var o=document.createElement('option');o.value=c.city;o.textContent=c.city;citySel.appendChild(o);});
citySel.addEventListener('change',function(){mapState.city=this.value;rebuildMap();});
document.getElementById('m-min').addEventListener('change',function(){mapState.min=+this.value;rebuildMap();});
document.getElementById('m-q').addEventListener('input',function(){mapState.q=this.value;rebuildMap();});
document.getElementById('m-apply').addEventListener('click',function(){mapState.from=document.getElementById('m-from').value;mapState.to=document.getElementById('m-to').value;rebuildMap();});
document.getElementById('m-reset').addEventListener('click',function(){mapState={from:'',to:'',acts:new Set(),city:'',min:0,q:''};document.getElementById('m-from').value='';document.getElementById('m-to').value='';document.getElementById('m-city').value='';document.getElementById('m-min').value='0';document.getElementById('m-q').value='';document.getElementById('map-types').querySelectorAll('.chip').forEach(function(x){x.classList.remove('on');});document.getElementById('map-presets').querySelectorAll('.chip').forEach(function(x,i){x.classList.toggle('on',i===3);});rebuildMap();});
document.getElementById('t-track').addEventListener('change',function(){mapState.track=this.checked;drawTrack();});
document.getElementById('t-heat').addEventListener('change',function(){mapState.heat=this.checked;if(mapState.heat)drawHeat(filteredPlaces());else if(heatLayer){FULLMAP.removeLayer(heatLayer);heatLayer=null;}});
document.getElementById('t-city').addEventListener('change',function(){mapState.cityAgg=this.checked;if(mapState.cityAgg)drawCityAgg();else if(cityLayer){FULLMAP.removeLayer(cityLayer);cityLayer=null;}});
// 图例
var lg='<div style="font-weight:600;color:var(--text-2)">类型分布</div>';
S.types.slice(0,8).forEach(function(t){lg+='<div class="lg"><i style="background:'+t.color+'"></i>'+t.icon+' '+t.name+' <span class="muted">'+num(t.count)+'</span></div>';});
document.getElementById('map-legend').innerHTML=lg;
// 窄屏折叠筛选面板(桌面端按钮不显示,面板常开)
var panel=document.getElementById('map-panel'), foldBtn=document.getElementById('panel-fold'), caret=document.getElementById('pf-caret');
var mq=window.matchMedia('(max-width:720px)');
function foldApply(c){panel.classList.toggle('collapsed',c);foldBtn.setAttribute('aria-expanded',String(!c));if(caret)caret.textContent=c?'▸':'▾';}
foldApply(mq.matches);
foldBtn.addEventListener('click',function(){foldApply(!panel.classList.contains('collapsed'));});
rebuildMap();
// 时间轴回放
setupTimeline();
}
function daysAgo(n){return new Date(Date.now()-n*86400000);}
var tlAll=[],tlMin=0,tlMax=0,tlDay=86400000,tlLastDay=null,tlTimer=null;
function setupTimeline(){
tlAll=PLACES.filter(function(p){return p.visits.length>0;}).map(function(p){return {p:p,ft:Math.min.apply(null,p.visits.map(function(v){return v.arrival;}))};});
if(tlAll.length<2){document.getElementById('timeline-bar').hidden=true;return;}
tlAll.forEach(function(x){if(x.ft<tlMin||tlMin===0)tlMin=x.ft;if(x.ft>tlMax)tlMax=x.ft;});
tlMin=Math.floor(tlMin/tlDay)*tlDay; tlMax=Math.ceil(tlMax/tlDay)*tlDay;
var rng=document.getElementById('tl-range'); rng.min=tlMin; rng.max=tlMax; rng.step=tlDay; rng.value=tlMax;
tlLastDay=null; document.getElementById('timeline-bar').hidden=false; applyTimeline();
}
function applyTimeline(){
if(!tlAll.length)return;
var cut=+document.getElementById('tl-range').value;
var day=Math.floor(cut/tlDay); if(day===tlLastDay)return; tlLastDay=day;
var sub=[],shown=0;
tlAll.forEach(function(x){if(x.ft<cut+tlDay){sub.push(x.p);shown++;}});
if(cluster)FULLMAP.removeLayer(cluster);
cluster=L.markerClusterGroup({showCoverageOnHover:false,maxClusterRadius:45,iconCreateFunction:clusterIcon});
sub.forEach(function(p){cluster.addLayer(makeMarker(p));});
FULLMAP.addLayer(cluster);
document.getElementById('pt-count').textContent=shown;
var pfc2=document.getElementById('pf-count'); if(pfc2)pfc2.textContent=shown+' 个地点';
document.getElementById('tl-label').textContent=(new Date(cut)).getFullYear()+'-'+String((new Date(cut)).getMonth()+1).padStart(2,'0')+' · '+shown+'/'+tlAll.length+' 个点';
}
document.getElementById('tl-range').addEventListener('input',function(){if(tlTimer)return;tlTimer=requestAnimationFrame(function(){tlTimer=null;applyTimeline();});});
document.getElementById('tl-range').addEventListener('change',function(){tlLastDay=null;applyTimeline();});
document.getElementById('tl-reset').addEventListener('click',function(){stopPlay();document.getElementById('tl-range').value=tlMax;tlLastDay=null;applyTimeline();});
var tlPlayTimer=null;
document.getElementById('tl-play').addEventListener('click',function(){
var btn=document.getElementById('tl-play');
if(tlPlayTimer){stopPlay();return;}
if(+document.getElementById('tl-range').value>=tlMax){document.getElementById('tl-range').value=tlMin;tlLastDay=null;}
btn.textContent='⏸';
var steps=Math.max(1,Math.round((tlMax-+document.getElementById('tl-range').value)/tlDay));
var per=Math.max(1,Math.ceil(steps/240));
tlPlayTimer=setInterval(function(){var next=+document.getElementById('tl-range').value+per*tlDay;if(next>=tlMax){next=tlMax;stopPlay();}document.getElementById('tl-range').value=next;applyTimeline();},50);
});
function stopPlay(){if(tlPlayTimer){clearInterval(tlPlayTimer);tlPlayTimer=null;}document.getElementById('tl-play').textContent='▶';}
/* ============ 路由 ============ */
var SECTIONS=['home','map','places','timeline','stats'];
function currentHash(){var h=location.hash.replace('#','');return SECTIONS.indexOf(h)>=0?h:'home';}
function showView(v){
SECTIONS.forEach(function(s){document.getElementById('view-'+s).style.display=(s===v?'block':'none');});
document.querySelectorAll('.nav-links a').forEach(function(a){a.classList.toggle('on',a.dataset.view===v);});
if(v==='map'){ensureMap();setTimeout(function(){if(FULLMAP)FULLMAP.invalidateSize();},40);}
if(v==='home'){ensurePreview();setTimeout(function(){if(PREVIEW)PREVIEW.invalidateSize();},40);}
window.scrollTo(0,0);
}
window.addEventListener('hashchange',function(){showView(currentHash());});
/* ============ 首次渲染 ============ */
renderHome(); renderPlaces(); renderTimeline(); renderStats();
showView(currentHash());
</script>
</body>
</html>