先出方案(frontend-plan-2026-10-09.md)再动手,四批按顺序落地。方法上先存基线、再用同一份 探针在改动前后逐值 diff —— 这批里有 6 处问题只有「量」才看得见,看截图全是正常的。 A 设计令牌(app.css 为主) - 字阶收敛到 6 档 --fs-xs/sm/md/lg/xl/2xl = 11/13/15/21/26/34px。原来 app.css 有 19 种字号、 模板里另有 54 处内联字号,光 12~15px 之间就挤了 4 档 —— 层级挤不出来,只能靠加粗硬顶。 - 两个「字外档」不是漏的:--fs-control: 16px(窄屏表单控件必须 ≥16px,否则 iOS 聚焦放大整页, 属功能约束);--glyph-*(✕/箭头/空态图标按控件尺寸定,硬塞进字阶只会把按钮撑坏)。 - 字重收敛到 400/500/600(删掉 650 与 700)。 - 圆角 4 档 --r-*、阴影 3 档 --sh-1/2/3(暗色整套覆盖)、间距 --sp-1..5、 卡片内边距单列 --card-pad(与折叠卡片标题行的负 margin 绑在一起,改一个即改两处)。 - 门禁 TestNoBareFontSizePx:静态扫 app.css 与全部模板,出现 font-size: <数字>px 即红。 B 卡片分主次 - .card--hero / .card(默认)/ .card--quiet 三档。quiet 走「页面底色 + 细边 + 无阴影」—— 第一版拿 --bg-2 当底色,实测它与页面底 #f6f7f8 只差 4/255,看不出边界、像破了个洞。 - 首屏 KPI 从「4×2 等重白盒」改成 .kpi-lead(主数 + 一行上下文)+ 5 个常规指标,8 个数字一个没少。 C 图表说人话 - 月度分布:柱底加基线;峰值柱不透明拉满、柱顶标次数(.pv),副标题里不再重复写「最高 N 次」。 - 一天中的分布:把「0/6/12/18 四个数字」换成 6 小时一段的标尺(深夜/上午/下午/晚上), 用同一套 24 列网格,段边界与柱子严格对齐。 - 首页月度图同样加峰值锚点(给 HomeData 补 MaxMonthly,与统计页同口径 maxOf)。 - 空态没动:查下来已经统一(8 页共用 emptystate、局部走 .empty)。 D1 类型色语义化(结论与计划不同,按实测改了) - 先算对比度:白底上 yellow 1.87 / mint 2.12 / orange 2.20 / green 2.22 / cyan 2.54 / teal 2.57, 6 个色连图形对象 3:1 都到不了;暗底只有 indigo 2.90 不够。 - 所以**没有**把类型色用到 chips / 表格类型列 / KPI 强调 —— 那几处要的是文字色,只会更难读。 这也解释了全站为什么一直只用 8px 小圆点做身份标记。浅色那 6 个色一个没动: 它们是手机端与地图上的同一套身份色,改暗会破坏对应关系。 - 实际做的:色值收口成 --cat-* / --cat-*-soft(26 个变量);模板 colorof → csscolor / csssoft(19 处); 暗色只提亮 indigo(2.90 → 3.55:1);colorOf 成了死代码,删掉。 - ⚠️ 返回值必须是 template.CSS:html/template 的 CSS 过滤器不认识 var(),普通 string 会被静默 换成 ZgotmplZ —— 页面上所有圆点当场变透明,而且不报错、模板测试照过。挂了它等于关掉那道过滤, 所以数据侧色值先过严格校验(只有 # + 6 位 hex 放行),否则 #"><img… 能逃出 style 属性。 守卫 TestCSSColorSurvivesTemplateSanitizer 把两个方向都钉住(做过变异验证)。 D2 窄屏导航 - 改前 ≤620px 靠横向滚动兜底:390 溢 63px、320 溢 133px,「统计」被切成半个字紧贴后面的按钮, 渐隐提示实际看不出来。现在链接本体抽成 {{define "navitems"}} 渲染两遍:桌面 .nav-links 平铺、 窄屏 .nav-menu(原生 <details>,不靠 JS 也能开合);JS 只补点外面 / Esc / 点链接后收起。 - ⚠️ 顶栏 --z-nav 抬到 1200:菜单是 .nav 的子元素、自己抬不动层叠上下文,不抬就会被地图页的 --z-map-overlay(1100) 盖住。已在 /map 上用 elementFromPoint 实测。 - 守卫 TestNarrowNavCollapsesToMenu 钉「CSS 两半 + 模板两个类 + 渲染两遍」的成对关系。 途中发现并一并修掉的(都只有量 DOM 才看得见) - Leaflet 的 .leaflet-container 字号是 0.75rem、跟着根字号漂:根 14 → 13px 后地图署名/比例尺 掉到 9.8px(低于本站 11px 下限)、弹窗正文也跟着缩。已按自己的字阶钉死。 - h1..h4 原来只设了字重与 margin、没设字号,靠浏览器默认的 2em/1.5em/1.17em —— 同样跟着根字号漂: /stats 那两个 h2 从 21px 变成 19.5px,比同页卡片标题大一圈。已显式给出字号。 - <b>/<strong> 浏览器默认 700,比全站最高层级(标题 600)还重,已钉成 600。 回归 - ./scripts/verify.sh:PASS=348 FAIL=0 SKIP=0 - internal/fog 加 -race:PASS=22 FAIL=0 SKIP=0 - JS 单测 34/34 - 移动端 320 / 390 溢出全 0(改前 /admin 在 320 还溢 34px);桌面 1440 与改前一致(−15 是滚动条宽) - 全站 7 个页面都没有低于 11px 的文字 说明:A/B 与 C/D 本想拆两笔提交,但 app.css / layout.html / view.go 里两批改动交织,按文件拆不开、 按 hunk 拆又容易做出「中间那笔编译不过」的历史,所以合成一笔、在消息里分段。
426 lines
20 KiB
HTML
426 lines
20 KiB
HTML
{{define "content"}}
|
||
{{if not .HasData}}
|
||
<div class="wrap">{{template "emptystate" .}}</div>
|
||
{{else}}
|
||
<div class="map-page">
|
||
<div id="map"></div>
|
||
|
||
{{/* 时间轴回放:拖动即可按「首次到访时间」累加显示点位。
|
||
注意它和左侧的日期区间是两回事——区间是「只看这段时间」,
|
||
时间轴是「一路走到这个时刻为止」,所以放在地图底部而不是筛选面板里。 */}}
|
||
<div class="timeline-bar" id="timeline-bar" hidden>
|
||
<button class="tl-play" type="button" id="tl-play" 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" type="button" id="tl-reset">全部</button>
|
||
</div>
|
||
|
||
{{/* 加载失败时的提示条:绝不重新加载页面,只在地图上说清楚并给一个重试按钮 */}}
|
||
<div class="map-status" id="map-status" hidden>
|
||
<span id="map-status-text"></span>
|
||
<button class="btn sm" type="button" id="map-retry" hidden>重试</button>
|
||
</div>
|
||
|
||
<div class="map-panel" id="map-panel">
|
||
{{/* 窄屏折叠开关:面板在手机上铺满整行、还盖住小半个地图,默认收成这一条。
|
||
桌面端它 display:none,面板照旧常开。窄屏的计数只在这里(表单里那行被隐藏),
|
||
展开/收起都要靠它显示当前筛出多少个点。 */}}
|
||
<button class="panel-fold" id="panel-fold" type="button" aria-controls="filter-form" aria-expanded="true">
|
||
<span>筛选</span>
|
||
<span class="pf-n" id="pf-count">{{num (len .Points)}} 个地点</span>
|
||
<span class="pf-c" id="pf-caret" aria-hidden="true">▾</span>
|
||
</button>
|
||
<form id="filter-form" method="get" action="/map">
|
||
<h3>时间范围</h3>
|
||
<div class="chips presets">
|
||
{{range .Presets}}<a class="chip {{if .Active}}on{{end}}" href="{{$.RangeLink .From .To}}">{{.Label}}</a>{{end}}
|
||
</div>
|
||
{{if .IsAdmin}}
|
||
{{/* 「含家/工作」原来跟在预设行末尾,而窄屏预设行改成单行横滑后它被滑到屏幕外、
|
||
没有任何提示(站长实测发现)。挪出来单独一行——它本来也不是时间段预设,
|
||
而是「这两类记录给不给自己看」的开关。 */}}
|
||
<div class="chips hw-row">
|
||
<a class="chip {{if .ShowHomeWork}}on{{end}}" href="{{.HWLink}}" title="仅登录后的你自己可见,访客看不到这两类记录">含家/工作</a>
|
||
</div>
|
||
{{end}}
|
||
<div class="row" style="gap:8px;margin-bottom:10px">
|
||
<input type="date" name="from" value="{{.From}}" style="flex:1">
|
||
<input type="date" name="to" value="{{.To}}" style="flex:1">
|
||
</div>
|
||
|
||
{{if .Activities}}
|
||
{{/* 12 个类型标签在窄屏要占两百多像素,是面板里最高的一块,所以整块做成可折叠:
|
||
标题里带上已选的类型名,收起时也知道当前选了什么。默认展开/收起由下面的脚本按视口定。 */}}
|
||
<details class="type-fold" id="type-fold" open>
|
||
<summary>
|
||
<span>地点类型</span>
|
||
<span class="tf-sel">{{if .ActivityIDs}}{{range $i, $a := .Activities}}{{if $.IsAct $a.ID}}{{$a.Name}} {{end}}{{end}}{{else}}全部{{end}}</span>
|
||
<span class="tf-arrow" aria-hidden="true">▸</span>
|
||
</summary>
|
||
<div class="chips" style="margin-bottom:10px">
|
||
{{range $i, $a := .Activities}}{{if or (lt $i 12) ($.IsAct $a.ID)}}
|
||
<a class="chip {{if $.IsAct $a.ID}}on{{end}}" href="{{$.ActLink $a.ID}}">
|
||
<span class="dot" style="background:{{csscolor $a.Color}}"></span>{{$a.Name}}
|
||
</a>
|
||
{{end}}{{end}}
|
||
</div>
|
||
{{if gt (len .Activities) 12}}
|
||
<details class="more-types">
|
||
<summary>展开其余 {{len .Activities}} 个类型</summary>
|
||
<div class="chips">
|
||
{{range $i, $a := .Activities}}{{if and (ge $i 12) (not ($.IsAct $a.ID))}}
|
||
<a class="chip" href="{{$.ActLink $a.ID}}"><span class="dot" style="background:{{csscolor $a.Color}}"></span>{{$a.Name}}</a>
|
||
{{end}}{{end}}
|
||
</div>
|
||
</details>
|
||
{{end}}
|
||
</details>
|
||
{{end}}
|
||
|
||
{{/* 窄屏把「其它条件 + 图层开关」整块收进 details:这两块加起来近 240px,不收的话
|
||
展开后面板仍要高出一屏。默认展开/收起由下面的脚本按视口定(桌面展开、窄屏收起)。
|
||
标题里带上已生效的条件,收起时也知道当前不是「无条件」。
|
||
act / hw 这两个 hidden 留在 details 外面——表单要始终带上它们。 */}}
|
||
<details class="adv-fold" id="adv-fold">
|
||
<summary>
|
||
<span>更多筛选</span>
|
||
<span class="tf-sel">{{if or .City .Keyword .TagID (gt .MinDwell 0)}}{{if .City}}{{.City}} {{end}}{{range .TagOptions}}{{if and (eq .ID $.TagID) (ne .ID 0)}}{{.Name}} {{end}}{{end}}{{if gt .MinDwell 0}}停留≥{{.MinDwell}}分 {{end}}{{if .Keyword}}“{{.Keyword}}”{{end}}{{else}}城市 / 标签 / 停留 / 关键词{{end}}</span>
|
||
<span class="tf-arrow" aria-hidden="true">▸</span>
|
||
</summary>
|
||
|
||
<h3>其它条件</h3>
|
||
<div class="row" style="gap:8px;margin-bottom:10px">
|
||
<select name="city" style="flex:1">
|
||
<option value="">全部城市</option>
|
||
{{range .CityList}}<option value="{{.}}" {{if eq . $.City}}selected{{end}}>{{.}}</option>{{end}}
|
||
</select>
|
||
<select name="tag" style="flex:1">
|
||
<option value="0">全部标签</option>
|
||
{{range .TagOptions}}<option value="{{.ID}}" {{if eq .ID $.TagID}}selected{{end}}>{{.Name}}</option>{{end}}
|
||
</select>
|
||
</div>
|
||
<div class="row" style="gap:8px;margin-bottom:12px">
|
||
<select name="min" style="flex:1">
|
||
<option value="0" {{if eq .MinDwell 0}}selected{{end}}>不限停留</option>
|
||
<option value="5" {{if eq .MinDwell 5}}selected{{end}}>≥ 5 分钟</option>
|
||
<option value="15" {{if eq .MinDwell 15}}selected{{end}}>≥ 15 分钟</option>
|
||
<option value="30" {{if eq .MinDwell 30}}selected{{end}}>≥ 30 分钟</option>
|
||
<option value="60" {{if eq .MinDwell 60}}selected{{end}}>≥ 1 小时</option>
|
||
</select>
|
||
<input type="text" name="q" value="{{.Keyword}}" placeholder="关键词" style="flex:1">
|
||
</div>
|
||
|
||
<div class="chips" style="margin-bottom:12px">
|
||
<label class="chip" style="user-select:none"><input type="checkbox" id="track-toggle" style="accent-color:var(--accent)"> 行程轨迹</label>
|
||
<label class="chip" style="user-select:none"><input type="checkbox" id="heat-toggle" style="accent-color:var(--accent)"> 到访热力</label>
|
||
<label class="chip" style="user-select:none"><input type="checkbox" id="city-toggle" style="accent-color:var(--accent)"> 城市聚合</label>
|
||
<label class="chip" style="user-select:none"><input type="checkbox" id="gpx-toggle" style="accent-color:var(--accent)"> GPX 轨迹</label>
|
||
</div>
|
||
</details>
|
||
|
||
{{range .ActivityIDs}}<input type="hidden" name="act" value="{{.}}">{{end}}
|
||
{{if .ShowHomeWork}}<input type="hidden" name="hw" value="1">{{end}}
|
||
|
||
{{/* 计数:桌面端就留在表单里(折叠条在桌面 display:none,没有别处可放);
|
||
窄屏这行整行隐藏,改由粘顶条上的 #pf-count 承担——窄屏差的正是这一行的高度。 */}}
|
||
<div class="map-count muted">当前显示 <b id="pt-count">{{num (len .Points)}}</b> 个地点</div>
|
||
|
||
<div class="row filter-actions">
|
||
<button class="btn" type="submit" style="flex:1">查询</button>
|
||
<a class="btn" href="/map">重置</a>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
{{if .Legend}}
|
||
<div class="map-legend">
|
||
<div style="font-weight:500;color:var(--text-2)">类型分布</div>
|
||
{{range .Legend}}
|
||
<div class="lg"><i style="background:{{csscolor .Color}}"></i>{{.Name}} <span class="muted">{{num .Count}}</span></div>
|
||
{{end}}
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
|
||
<script>
|
||
// 筛选面板折叠:状态同步执行,免得手机上先闪一下整块面板再收起。
|
||
// 只切换 class,具体收起后什么样由 CSS 的窄屏媒体查询决定(桌面端按钮本身就不显示)。
|
||
(function () {
|
||
var panel = document.getElementById('map-panel');
|
||
var btn = document.getElementById('panel-fold');
|
||
var caret = document.getElementById('pf-caret');
|
||
if (!panel || !btn) return;
|
||
|
||
var KEY = 'rond-map-fold';
|
||
var saved = null;
|
||
try { saved = localStorage.getItem(KEY); } catch (e) { /* 隐私模式禁用了 localStorage,忽略 */ }
|
||
|
||
function apply(collapsed) {
|
||
panel.classList.toggle('collapsed', collapsed);
|
||
btn.setAttribute('aria-expanded', String(!collapsed));
|
||
btn.title = collapsed ? '展开筛选' : '收起筛选';
|
||
if (caret) caret.textContent = collapsed ? '▸' : '▾';
|
||
}
|
||
// 没记过状态时窄屏默认收起——「全部」这种宽区间能有一两百个点,
|
||
// 面板一上来就占掉 50% 的屏幕,地图反而看不清。
|
||
var desk = window.matchMedia('(min-width: 621px)');
|
||
apply(saved === null ? !desk.matches : saved === '1');
|
||
|
||
// 地点类型 / 更多筛选同理:窄屏收起(两块各占两百多像素),桌面有地方就一直展开。
|
||
// 不记本地:窄屏上点类型或改筛选会整页跳转,重载后本就该收回去。
|
||
['type-fold', 'adv-fold'].forEach(function (id) {
|
||
var d = document.getElementById(id);
|
||
if (d) d.open = desk.matches;
|
||
});
|
||
|
||
btn.addEventListener('click', function () {
|
||
var collapsed = !panel.classList.contains('collapsed');
|
||
apply(collapsed);
|
||
try { localStorage.setItem(KEY, collapsed ? '1' : '0'); } catch (e) { /* 同上 */ }
|
||
});
|
||
})();
|
||
</script>
|
||
|
||
<script>
|
||
window.addEventListener('load', function () {
|
||
// 首屏不带地点数据(全量内联会让 HTML 膨胀到 ~58KB),
|
||
// 页面先出壳,地点走 /api/points 并行拉取后再填充。
|
||
window.__map = window.rondMap.full({ id: 'map', points: [], back: '{{.BackQS}}' });
|
||
|
||
var form = document.getElementById('filter-form');
|
||
var ptCount = document.getElementById('pt-count');
|
||
var statusBox = document.getElementById('map-status');
|
||
var statusText = document.getElementById('map-status-text');
|
||
var retryBtn = document.getElementById('map-retry');
|
||
var pfCount = document.getElementById('pf-count');
|
||
|
||
// 计数两处同步:桌面端表单里那行(窄屏被隐藏)、窄屏粘顶条上的 #pf-count
|
||
function setCount(n) {
|
||
if (ptCount) ptCount.textContent = n;
|
||
pfCount.textContent = n + ' 个地点';
|
||
}
|
||
|
||
function qs() {
|
||
return new URLSearchParams(new FormData(form)).toString();
|
||
}
|
||
|
||
function showStatus(msg, withRetry) {
|
||
statusText.textContent = msg;
|
||
retryBtn.hidden = !withRetry;
|
||
statusBox.hidden = false;
|
||
}
|
||
function hideStatus() { statusBox.hidden = true; }
|
||
|
||
// HTTP 500 时 fetch 本身是成功的,紧接着 r.json() 才抛错——统一在这里判掉,
|
||
// 调用方就只有一个失败分支要处理。
|
||
function loadJSON(url) {
|
||
return fetch(url, { headers: { 'Accept': 'application/json' } }).then(function (r) {
|
||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||
return r.json();
|
||
});
|
||
}
|
||
|
||
/* 以前这里是 `.catch(function () { form.submit(); })`:接口一失败就整页重提交,
|
||
而重提交后的新页面又走同一条路 —— 实测 8 秒内自刷 17 次、接口被拒 16 次、
|
||
访客看不到任何解释,服务器被反复打(1C1G 上尤其难受)。
|
||
现在改成:有限次自动重试 + 明确提示 + 手动重试按钮,**任何情况下都不重新加载页面**。 */
|
||
var RETRY_DELAYS = [1000, 3000];
|
||
|
||
// 返回的 promise 一定 resolve(true/false),不会 reject——避免未处理的 rejection,
|
||
// 也让调用方只需判断「这次到底加载成功了没」。
|
||
function loadPoints(q, attempt) {
|
||
attempt = attempt || 0;
|
||
return loadJSON('/api/points' + (q ? '?' + q : '')).then(function (d) {
|
||
var pts = d.points || [];
|
||
// 先交给时间轴:它自己会在「有时间的点」上做过滤再决定画什么
|
||
setupTimeline(pts);
|
||
window.rondMap.setPoints(pts);
|
||
setCount(pts.length);
|
||
hideStatus();
|
||
return true;
|
||
}).catch(function (err) {
|
||
var why = (err && err.message) ? err.message : '网络异常';
|
||
if (attempt < RETRY_DELAYS.length) {
|
||
showStatus('地点加载失败(' + why + '),正在重试…', false);
|
||
return new Promise(function (res) { setTimeout(res, RETRY_DELAYS[attempt]); })
|
||
.then(function () { return loadPoints(q, attempt + 1); });
|
||
}
|
||
showStatus('地点加载失败(' + why + ')。地图上暂时没有点,可以点右侧重试。', true);
|
||
return false;
|
||
});
|
||
}
|
||
|
||
function reloadPoints(q) {
|
||
// 地图页的筛选是 AJAX 换数据、不跳页,所以顶部进度条要自己收
|
||
if (window.rondProgress) window.rondProgress.start();
|
||
return loadPoints(q).then(function (ok) {
|
||
if (ok && document.getElementById('track-toggle').checked) refreshTrack();
|
||
if (window.rondProgress) window.rondProgress.finish();
|
||
return ok;
|
||
});
|
||
}
|
||
|
||
// 与列表页保持一致:下拉 / 日期改动即生效,不用再点「应用筛选」。
|
||
// 关键词仍然回车才查(每敲一个字都发请求很难受)。
|
||
// 这里走的是本页自己的 submit 处理器(AJAX 换数据),所以用 requestSubmit。
|
||
form.addEventListener('change', function (e) {
|
||
var el = e.target;
|
||
if (!el || !el.name) return;
|
||
if (el.tagName === 'SELECT' || el.type === 'date') {
|
||
if (form.requestSubmit) form.requestSubmit();
|
||
else form.submit();
|
||
}
|
||
});
|
||
|
||
retryBtn.addEventListener('click', function () {
|
||
retryBtn.hidden = true;
|
||
hideStatus();
|
||
reloadPoints(qs());
|
||
});
|
||
// 从后台切回来时顺手重试一次:多半是刚才断网/休眠导致的失败
|
||
document.addEventListener('visibilitychange', function () {
|
||
if (!document.hidden && !statusBox.hidden) retryBtn.click();
|
||
});
|
||
|
||
/* ---------- 时间轴回放 ----------
|
||
* 每个点带着「首次到访时间」(ft,Unix 秒),拖动滑块就把 ft ≤ 截止时刻的点留下。
|
||
* 这样得到的是「一路走到这个时刻,足迹长什么样」的累加效果。
|
||
* 实现上不去逐点改样式,而是复用 setPoints 整体重画——点位规模是几百,
|
||
* 且只在「日期真的变了」时才重画(拖动一次可能跨很多像素但只跨几天)。
|
||
*/
|
||
var tlBar = document.getElementById('timeline-bar');
|
||
var tlRange = document.getElementById('tl-range');
|
||
var tlLabel = document.getElementById('tl-label');
|
||
var tlPlay = document.getElementById('tl-play');
|
||
var allPoints = []; // 最近一次拉取到的全部点(时间轴始终在它上面过滤)
|
||
var tlMin = 0, tlMax = 0, tlDay = 86400, tlLastDay = null, tlTimer = null;
|
||
|
||
function fmtDay(sec) {
|
||
var d = new Date(sec * 1000);
|
||
return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2);
|
||
}
|
||
|
||
function applyTimeline() {
|
||
if (!allPoints.length) return;
|
||
var cut = Number(tlRange.value);
|
||
// 只跟着「天」变化重画:拖动时像素变化远多于天数变化,不然一帧要重画好几次
|
||
var day = Math.floor(cut / tlDay);
|
||
if (day === tlLastDay) return;
|
||
tlLastDay = day;
|
||
var sub = [], shown = 0;
|
||
for (var i = 0; i < allPoints.length; i++) {
|
||
var p2 = allPoints[i];
|
||
// 「截至某日」要把那一天也算进去,否则拖到最左会是一个空地图
|
||
if (!p2.ft || p2.ft < cut + tlDay) { sub.push(p2); shown++; }
|
||
}
|
||
// keepView:拖动/播放时不要重新 fit 视野,不然地图会跟着不停缩放,很晕
|
||
window.rondMap.setPoints(sub, { keepView: true });
|
||
tlLabel.textContent = fmtDay(cut) + ' · ' + shown + '/' + allPoints.length + ' 个点';
|
||
}
|
||
|
||
function setupTimeline(pts) {
|
||
allPoints = pts;
|
||
var withTime = pts.filter(function (p2) { return p2.ft; });
|
||
if (withTime.length < 2) { tlBar.hidden = true; return; }
|
||
tlMin = withTime[0].ft;
|
||
tlMax = withTime[0].ft;
|
||
for (var i = 1; i < withTime.length; i++) {
|
||
if (withTime[i].ft < tlMin) tlMin = withTime[i].ft;
|
||
if (withTime[i].ft > tlMax) tlMax = withTime[i].ft;
|
||
}
|
||
// 两端都对齐到「天」:input[type=range] 的可取值为 min + k*step,
|
||
// max 不是 step 的整数倍时**根本取不到 max**,全量时就会少算几个点。
|
||
tlMin = Math.floor(tlMin / tlDay) * tlDay;
|
||
tlMax = Math.ceil(tlMax / tlDay) * tlDay;
|
||
tlRange.min = tlMin;
|
||
tlRange.max = tlMax;
|
||
tlRange.step = tlDay;
|
||
tlRange.value = tlMax;
|
||
tlLastDay = null;
|
||
tlBar.hidden = false;
|
||
applyTimeline();
|
||
}
|
||
|
||
tlRange.addEventListener('input', function () {
|
||
if (tlTimer) return; // 一帧只重画一次,拖起来才不卡
|
||
tlTimer = requestAnimationFrame(function () { tlTimer = null; applyTimeline(); });
|
||
});
|
||
tlRange.addEventListener('change', function () { tlLastDay = null; applyTimeline(); });
|
||
document.getElementById('tl-reset').addEventListener('click', function () {
|
||
stopPlay();
|
||
tlRange.value = tlMax;
|
||
tlLastDay = null;
|
||
applyTimeline();
|
||
});
|
||
|
||
// 播放:约 12 秒走完整个跨度(按天推进,和拖动走同一条路径)
|
||
function stopPlay() {
|
||
if (tlTimer) { cancelAnimationFrame(tlTimer); tlTimer = null; }
|
||
if (tlPlay._t) { clearInterval(tlPlay._t); tlPlay._t = null; }
|
||
tlPlay.textContent = '▶';
|
||
tlPlay.setAttribute('aria-label', '播放时间轴');
|
||
}
|
||
tlPlay.addEventListener('click', function () {
|
||
if (tlPlay._t) { stopPlay(); return; }
|
||
if (Number(tlRange.value) >= tlMax) { tlRange.value = tlMin; tlLastDay = null; }
|
||
tlPlay.textContent = '⏸';
|
||
tlPlay.setAttribute('aria-label', '暂停');
|
||
var steps = Math.max(1, Math.round((tlMax - Number(tlRange.value)) / tlDay));
|
||
var perTick = Math.max(1, Math.ceil(steps / 240)); // 最多约 240 帧,约 12 秒
|
||
tlPlay._t = setInterval(function () {
|
||
var next = Number(tlRange.value) + perTick * tlDay;
|
||
if (next >= tlMax) { next = tlMax; stopPlay(); }
|
||
tlRange.value = next;
|
||
applyTimeline();
|
||
}, 50);
|
||
});
|
||
|
||
reloadPoints(qs());
|
||
form.addEventListener('submit', function (e) {
|
||
e.preventDefault();
|
||
var q = qs();
|
||
history.replaceState(null, '', '/map' + (q ? '?' + q : ''));
|
||
reloadPoints(q);
|
||
});
|
||
|
||
var trackOn = false;
|
||
// 与图层开关同理:勾选后马上取消时请求还在飞,回包会把轨迹画回来;
|
||
// 序号顺带解决「连点筛选 / 切底图」时旧回包盖掉新回包。
|
||
var trackSeq = 0;
|
||
function refreshTrack() {
|
||
var seq = ++trackSeq;
|
||
if (!trackOn) { window.rondMap.setTrack(null, null); return; }
|
||
var q = qs();
|
||
loadJSON('/api/track' + (q ? '?' + q : ''))
|
||
.then(function (d) {
|
||
if (seq !== trackSeq) return;
|
||
window.rondMap.setTrack(d.segs || [], d.modes || []);
|
||
})
|
||
// 轨迹拉不到只影响这一层,不阻塞地图本身;安静失败 + 取消勾选,别让开关停在"已选但没线"的状态
|
||
.catch(function (err) {
|
||
if (seq !== trackSeq) return;
|
||
trackOn = false;
|
||
document.getElementById('track-toggle').checked = false;
|
||
window.rondMap.setTrack(null, null);
|
||
showStatus('行程轨迹加载失败(' + ((err && err.message) || '网络异常') + '),已取消勾选。', false);
|
||
setTimeout(hideStatus, 4000);
|
||
});
|
||
}
|
||
document.getElementById('track-toggle').addEventListener('change', function () {
|
||
trackOn = this.checked;
|
||
refreshTrack();
|
||
});
|
||
document.getElementById('heat-toggle').addEventListener('change', function () {
|
||
window.rondMap.setHeat(this.checked);
|
||
});
|
||
document.getElementById('city-toggle').addEventListener('change', function () {
|
||
window.rondMap.setCityAgg(this.checked);
|
||
});
|
||
document.getElementById('gpx-toggle').addEventListener('change', function () {
|
||
window.rondMap.setGpx(this.checked);
|
||
});
|
||
});
|
||
</script>
|
||
{{end}}
|
||
{{end}}
|