Files
rond-with-you/internal/web/templates/map.html
T

418 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
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.
{{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:{{colorof $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:{{colorof $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:{{colorof .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: [] });
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;
function refreshTrack() {
if (!trackOn) { window.rondMap.setTrack(null, null); return; }
var q = qs();
loadJSON('/api/track' + (q ? '?' + q : ''))
.then(function (d) { window.rondMap.setTrack(d.segs || [], d.modes || []); })
// 轨迹拉不到只影响这一层,不阻塞地图本身;安静失败 + 取消勾选,别让开关停在"已选但没线"的状态
.catch(function (err) {
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}}