first release
@@ -0,0 +1,900 @@
|
||||
:root {
|
||||
--accent: #2f7d6e;
|
||||
--accent-soft: #e8f3f0;
|
||||
--bg: #f6f7f8;
|
||||
--panel: #ffffff;
|
||||
--line: #e5e7ea;
|
||||
--line-soft: #f0f1f3;
|
||||
--text: #1f2328;
|
||||
--text-2: #5b6169;
|
||||
/* 次要文字。原来是 #8b9199,对白底只有 3.18:1、对浅灰底 2.81:1,
|
||||
低于 WCAG AA 的 4.5:1(页面副标题、KPI 标签、筛选计数全用这个色阶)。
|
||||
改成 #666d75 之后:白底 5.24 / 浅灰底 4.63 / 绿浅底 4.62,
|
||||
既达标又仍比 --text-2(5.53)浅,层次没丢。 */
|
||||
--text-3: #666d75;
|
||||
/* 会随明暗切换的「面」色,抽出来暗色只需换这一组 */
|
||||
--surface: #ffffff; /* 输入框 / 按钮 / 浮层的底 */
|
||||
--glass: rgba(255, 255, 255, .92); /* 顶栏毛玻璃 */
|
||||
--glass-strong: rgba(255, 255, 255, .97); /* 地图面板 / 图例 */
|
||||
--row-hover: #fbfcfc;
|
||||
--bg-2: #fafbfb; /* 比卡片略深一层的面(行内编辑表单、空态框) */
|
||||
--on-accent: #ffffff; /* 主色底上的文字 */
|
||||
--ok-bg: #eef8f5; --ok-fg: #17604f; --ok-line: #cfe6e0;
|
||||
--err-bg: #fdf3f2; --err-fg: #b42318; --err-line: #f3dcda;
|
||||
--info-bg: #f2f6fd; --info-fg: #2c5282; --info-line: #dbe5f5;
|
||||
--radius: 10px;
|
||||
--shadow: 0 1px 2px rgba(16, 24, 40, .04), 0 1px 3px rgba(16, 24, 40, .06);
|
||||
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
/* 顶栏高度:窄屏更矮。地图页的定位跟它对齐,避免出现几像素的缝。 */
|
||||
--nav-h: 54px;
|
||||
/* 刘海屏 / 底部 Home 指示条的安全区。不支持 env() 的浏览器回落到 0。 */
|
||||
--safe-t: env(safe-area-inset-top, 0px);
|
||||
--safe-r: env(safe-area-inset-right, 0px);
|
||||
--safe-b: env(safe-area-inset-bottom, 0px);
|
||||
--safe-l: env(safe-area-inset-left, 0px);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
h1, h2, h3, h4 { margin: 0; font-weight: 600; letter-spacing: -.01em; }
|
||||
|
||||
/* ---------- 顶栏 ---------- */
|
||||
.nav {
|
||||
position: sticky; top: 0; z-index: 500;
|
||||
background: var(--glass);
|
||||
backdrop-filter: saturate(1.6) blur(10px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
/* 横屏时刘海在左右,竖屏时状态栏在顶部 */
|
||||
padding: var(--safe-t) var(--safe-r) 0 var(--safe-l);
|
||||
}
|
||||
.nav-inner {
|
||||
max-width: 1120px; margin: 0 auto; padding: 0 20px;
|
||||
height: var(--nav-h); display: flex; align-items: center; gap: 22px;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 8px; font-weight: 600; color: var(--text); font-size: 15px; }
|
||||
.brand:hover { text-decoration: none; }
|
||||
.brand .avatar { font-size: 18px; }
|
||||
.nav-links { display: flex; gap: 4px; flex: 1; }
|
||||
.nav-links a {
|
||||
padding: 5px 11px; border-radius: 7px; color: var(--text-2); font-size: 13.5px; white-space: nowrap;
|
||||
}
|
||||
.nav-links a:hover { background: var(--line-soft); color: var(--text); text-decoration: none; }
|
||||
.nav-links a.on { background: var(--accent-soft); color: var(--accent); font-weight: 500; }
|
||||
.nav-right { display: flex; align-items: center; gap: 10px; font-size: 13px; color: var(--text-3); }
|
||||
|
||||
/* ---------- 布局 ---------- */
|
||||
.wrap { max-width: 1120px; margin: 0 auto;
|
||||
padding: 22px calc(20px + var(--safe-r)) calc(60px + var(--safe-b)) calc(20px + var(--safe-l)); }
|
||||
.wrap-wide { max-width: 100%; padding: 0; }
|
||||
.page-head { margin-bottom: 18px; display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
||||
.page-head h1 { font-size: 21px; }
|
||||
.page-head .sub { color: var(--text-3); font-size: 13px; margin-top: 3px; }
|
||||
|
||||
.card {
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);
|
||||
box-shadow: var(--shadow); padding: 18px; margin-bottom: 16px;
|
||||
}
|
||||
.card > h2 { font-size: 15px; margin-bottom: 2px; display: flex; align-items: center; gap: 8px; }
|
||||
/* .card-body 是数据编辑页折叠用的容器,卡片内容被它包住后不再是 .card 的直接子元素,
|
||||
所以下面两条用「后代」而不是「子」选择器——否则折叠脚本一加上,间距就全没了。 */
|
||||
.card .card-sub { color: var(--text-3); font-size: 12.5px; margin-bottom: 14px; }
|
||||
.card.collapsed .card-body { display: none; }
|
||||
/* 只有带 data-card 的卡片(数据编辑页)能折叠:标题整行可点,右侧一个箭头 */
|
||||
.card[data-card] > h2 { cursor: pointer; }
|
||||
.card[data-card] > h2 .card-toggle {
|
||||
margin-left: auto; flex: 0 0 auto; cursor: pointer;
|
||||
background: none; border: 0; padding: 0 2px; color: var(--text-3);
|
||||
font: inherit; font-size: 12px; line-height: 1;
|
||||
}
|
||||
.card[data-card] > h2 .card-toggle:hover { color: var(--text); }
|
||||
.grid { display: grid; gap: 16px; }
|
||||
/* grid 子项默认 min-width:auto(= 不小于自身 min-content),而卡片里常常放着
|
||||
带 min-width 的表格——不加这条,卡片会拒绝收缩、把**整页**顶宽,出现横向滚动条
|
||||
(/stats、/report 在手机上整页 692px 就是这么来的)。压到 0 之后由卡片内部的
|
||||
.tw 自己横向滚动。 */
|
||||
.grid > * { min-width: 0; }
|
||||
.g2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.g3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.g4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
@media (max-width: 900px) { .g3, .g4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 620px) {
|
||||
:root { --nav-h: 50px; }
|
||||
.g2, .g3, .g4 { grid-template-columns: 1fr; }
|
||||
.wrap { padding: 16px calc(14px + var(--safe-r)) calc(48px + var(--safe-b)) calc(14px + var(--safe-l)); }
|
||||
.nav-inner { padding: 0 10px; gap: 6px; }
|
||||
/* 窄屏只留头像,导航可横向滚动,避免换行把布局撑坏 */
|
||||
.brand .t { display: none; }
|
||||
.nav-links { overflow-x: auto; scrollbar-width: none; gap: 0; }
|
||||
.nav-links::-webkit-scrollbar { display: none; }
|
||||
.nav-links a { padding: 5px 7px; font-size: 13px; }
|
||||
.nav-right .who { display: none; }
|
||||
.nav-right .btn.sm { padding: 4px 9px; font-size: 12.5px; }
|
||||
}
|
||||
|
||||
/* ---------- 窄屏触控与表单(移动端适配) ---------- */
|
||||
/* 手指的落点比鼠标粗得多:导航链接原本只有 40×31、筛选标签 59×28、小按钮 45×30,
|
||||
都低于 44×44 的通行下限,最容易点的就是它们。这里只放宽尺寸与内边距,不动结构。 */
|
||||
@media (max-width: 620px) {
|
||||
.nav-links a, .btn, .btn.sm, .chip { min-height: 44px; }
|
||||
.nav-links a { display: inline-flex; align-items: center; padding: 8px 9px; }
|
||||
.chip { padding: 9px 12px; }
|
||||
.btn, .btn.sm { padding: 10px 14px; }
|
||||
.check input { width: 20px; height: 20px; }
|
||||
/* iOS 聚焦字号小于 16px 的控件时会自动放大整页——用户点完输入框页面突然变大,
|
||||
还要手动缩回去。窄屏统一提到 16px 规避(桌面端不受影响,保持紧凑)。 */
|
||||
:root { --field-fs: 16px; }
|
||||
/* 表格里的控件仍然紧凑:后台那张表列很多,16px 会把列撑得很难用。
|
||||
表格里横向可滚,iOS 缩放的影响也小。 */
|
||||
.photo-table, table.tb { --field-fs: 14px; }
|
||||
}
|
||||
|
||||
.only-sm { display: none; }
|
||||
@media (max-width: 620px) {
|
||||
.hide-sm { display: none; }
|
||||
.only-sm { display: inline; }
|
||||
/* 窄屏用地势与手势缩放,把控件让给底部 */
|
||||
.map-panel { top: 8px; }
|
||||
}
|
||||
|
||||
/* ---------- 统计数字 ---------- */
|
||||
/* grid-auto-rows: 1fr 让所有格子等高:否则某一格的值换行时,那一整行都比别行高 */
|
||||
/* 用 flex + grow 而不是固定列数的网格:KPI 个数常常是 6、7 这种除不尽列数的,
|
||||
网格会在最后一行留下空格子——而间隙是用底色画的,空格子就露成灰块,很难看。
|
||||
flex 会让最后一行自己撑满,任何个数都不会有空格。 */
|
||||
.kpis { display: flex; flex-wrap: wrap; gap: 1px; background: var(--line); border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line); }
|
||||
@media (max-width: 900px) { .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
.kpi { flex: 1 1 158px; min-width: 0; background: var(--panel); padding: 15px 16px; }
|
||||
/* 字号跟着视口收:窄屏上「112天19小时」这种值放不下就会折成两行,把整行撑高 */
|
||||
.kpi .v { font-size: clamp(17px, 5.6vw, 23px); font-weight: 650; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
|
||||
.kpi .v small { font-size: 13px; font-weight: 500; color: var(--text-3); margin-left: 3px; }
|
||||
.kpi .k { color: var(--text-3); font-size: 12.5px; margin-top: 1px; }
|
||||
|
||||
/* ---------- 图表(纯 CSS/SVG) ---------- */
|
||||
.bars { display: flex; flex-direction: column; gap: 9px; }
|
||||
.bar-row { display: grid; grid-template-columns: 128px 1fr 104px; align-items: center; gap: 10px; font-size: 13px; }
|
||||
/* 整行可点(下钻为筛选):给一点悬停反馈,并让点击区域覆盖整行;
|
||||
柱条本身只有 9px 高,靠「行」来接收点击才够手指点 */
|
||||
a.bar-row { margin: 0 -6px; padding: 4px 6px; border-radius: 6px; color: inherit; text-decoration: none; }
|
||||
a.bar-row:hover { background: var(--line-soft); text-decoration: none; }
|
||||
a.col { display: block; text-decoration: none; }
|
||||
a.col:hover { opacity: 1; filter: brightness(1.12); }
|
||||
.bar-row .nm { color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.bar-track { background: var(--line-soft); border-radius: 5px; height: 9px; overflow: hidden; }
|
||||
.bar-fill { height: 100%; border-radius: 5px; background: var(--accent); min-width: 2px; }
|
||||
.bar-row .vv { text-align: right; color: var(--text-2); font-variant-numeric: tabular-nums; font-size: 12.5px; white-space: nowrap; }
|
||||
@media (max-width: 620px) {
|
||||
.bar-row { grid-template-columns: 84px 1fr 92px; }
|
||||
/* 图表行在手机上要能点中:整行 ~37px。再往上加(44px)会把「类型分布」这种
|
||||
60 多行的图撑到两倍高,翻起来更难受,这里取个折中。 */
|
||||
a.bar-row { padding: 8px 6px; }
|
||||
}
|
||||
|
||||
.cols { display: flex; align-items: flex-end; gap: 3px; height: 120px; }
|
||||
.cols .col { flex: 1; background: var(--accent); border-radius: 3px 3px 0 0; min-height: 2px; opacity: .85; }
|
||||
.cols .col:hover { opacity: 1; }
|
||||
.cols-x { display: flex; gap: 3px; font-size: 10px; color: var(--text-3); margin-top: 5px; }
|
||||
.cols-x span { flex: 1; text-align: center; overflow: hidden; white-space: nowrap; }
|
||||
|
||||
/* ---------- 逐月气温 ---------- */
|
||||
.temp-chart { margin-top: 16px; }
|
||||
.temp-head { font-size: 12.5px; color: var(--text-3); margin-bottom: 8px; }
|
||||
/* 横向可滚动:柱子固定宽,十几二十个月挤在窄屏上也不会把标签压成一团 */
|
||||
.temp-strip { display: flex; gap: 4px; overflow-x: auto; padding-bottom: 4px; }
|
||||
.temp-col { flex: 0 0 36px; display: flex; flex-direction: column; align-items: center; gap: 3px; cursor: default; }
|
||||
.temp-col .tc-v { font-size: 10.5px; color: var(--text-2); font-variant-numeric: tabular-nums; }
|
||||
.temp-col .tc-track { width: 100%; height: 84px; display: flex; align-items: flex-end; }
|
||||
.temp-col .tc-bar { width: 100%; border-radius: 4px 4px 0 0; min-height: 3px; }
|
||||
.temp-col .tc-m { font-size: 10.5px; color: var(--text-3); white-space: nowrap; }
|
||||
.temp-col:hover .tc-bar { filter: brightness(1.08); }
|
||||
.temp-col:hover .tc-v { color: var(--text); }
|
||||
|
||||
/* ---------- 日历热力 ---------- */
|
||||
.heat { display: flex; gap: 3px; overflow-x: auto; padding-bottom: 4px; }
|
||||
.heat .wk { display: flex; flex-direction: column; gap: 3px; }
|
||||
.heat .d { width: 11px; height: 11px; border-radius: 2.5px; background: var(--line-soft); }
|
||||
.heat .d.l1 { background: #cfe6e0; }
|
||||
.heat .d.l2 { background: #9dcfc3; }
|
||||
.heat .d.l3 { background: #5fae9c; }
|
||||
.heat .d.l4 { background: var(--accent); }
|
||||
.heat-legend { display: flex; align-items: center; gap: 5px; font-size: 11.5px; color: var(--text-3); margin-top: 8px; }
|
||||
.heat-legend .d { width: 11px; height: 11px; border-radius: 2.5px; }
|
||||
|
||||
/* ---------- 列表 ---------- */
|
||||
.list { display: flex; flex-direction: column; }
|
||||
.item {
|
||||
display: grid; grid-template-columns: 40px 1fr auto; gap: 12px; align-items: center;
|
||||
padding: 11px 2px; border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
.item:last-child { border-bottom: 0; }
|
||||
.item .ic {
|
||||
width: 34px; height: 34px; border-radius: 9px; display: flex; align-items: center; justify-content: center;
|
||||
background: var(--line-soft); font-size: 17px;
|
||||
}
|
||||
.item .t { font-weight: 500; }
|
||||
.item .t a { color: var(--text); }
|
||||
.item .m { color: var(--text-3); font-size: 12.5px; margin-top: 1px; }
|
||||
.item .r { text-align: right; color: var(--text-2); font-size: 12.5px; white-space: nowrap; }
|
||||
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 20px;
|
||||
background: var(--line-soft); color: var(--text-2); font-size: 12.5px; border: 1px solid transparent;
|
||||
}
|
||||
.chip.on { background: var(--accent-soft); color: var(--accent); border-color: #cfe6e0; font-weight: 500; }
|
||||
.chip .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
||||
.tag { display: inline-block; padding: 1px 7px; border-radius: 5px; background: var(--accent-soft); color: var(--accent); font-size: 11.5px; }
|
||||
|
||||
/* ---------- 表单 ---------- */
|
||||
label.f { display: block; margin-bottom: 13px; }
|
||||
label.f > span { display: block; font-size: 12.5px; color: var(--text-2); margin-bottom: 5px; }
|
||||
/* 必填标记:字段名后的红色星号,以及表单顶部的统一说明 */
|
||||
.req { color: #b42318; font-weight: 700; margin-left: 2px; }
|
||||
.req-tip { font-size: 12.5px; color: var(--text-2); margin: 0 0 10px; }
|
||||
.req-tip .req { margin-right: 3px; }
|
||||
|
||||
/* flatpickr 日期时间选择器:贴合站点风格 */
|
||||
.flatpickr-calendar { border-radius: 10px; box-shadow: 0 8px 30px rgba(0,0,0,.14); font-family: inherit; }
|
||||
.flatpickr-day.selected, .flatpickr-day.selected:hover { background: var(--accent); border-color: var(--accent); }
|
||||
.flatpickr-day.today { border-color: var(--accent); }
|
||||
.flatpickr-day.today:hover { background: var(--accent-soft); color: var(--text); }
|
||||
.flatpickr-day:hover { background: var(--accent-soft); }
|
||||
/* 控件字号走变量:窄屏要整体提到 16px(iOS 聚焦小于 16px 会自动放大整页),
|
||||
而基础选择器 input[type=…], select, textarea 的特异性高于媒体查询里的单选择器,
|
||||
直接在里面写 font-size 是压不过的——用可继承的自定义属性就没有这个问题。 */
|
||||
/* 按「排除掉不该套文本框样式的类型」来写,而不是逐个枚举 text/number/date——
|
||||
枚举漏了 datetime-local(后台编辑页有 160 多个),那些框就一直是浏览器默认外观:
|
||||
没有圆角、没有内边距、13.33px 的字,跟旁边的输入框明显不是一套。 */
|
||||
input:not([type=checkbox]):not([type=radio]):not([type=color]):not([type=range]):not([type=file]):not([type=submit]):not([type=button]):not([type=reset]):not([type=image]):not([type=hidden]),
|
||||
select, textarea {
|
||||
width: 100%; padding: 7px 10px; border: 1px solid var(--line); border-radius: 7px; font: inherit;
|
||||
font-size: var(--field-fs, inherit);
|
||||
background: var(--surface); color: var(--text); outline: none;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
.check { display: flex; align-items: center; gap: 7px; font-size: 13px; color: var(--text-2); margin-bottom: 8px; word-break: keep-all; overflow-wrap: break-word; }
|
||||
.check input { width: 15px; height: 15px; accent-color: var(--accent); }
|
||||
.nobr { white-space: nowrap; }
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: 7px;
|
||||
border: 1px solid var(--line); background: var(--surface); color: var(--text); font: inherit; font-size: 13.5px; cursor: pointer;
|
||||
}
|
||||
.btn:hover { background: var(--line-soft); text-decoration: none; }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||
.btn.primary:hover { filter: brightness(1.06); }
|
||||
.btn.sm { padding: 4px 10px; font-size: 12.5px; }
|
||||
.btn.danger { color: #b42318; border-color: #f0cecb; }
|
||||
.btn.danger:hover { background: var(--err-bg); }
|
||||
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.spacer { flex: 1; }
|
||||
|
||||
/* ---------- 表格 ---------- */
|
||||
table.tb { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
table.tb th {
|
||||
text-align: left; font-weight: 500; color: var(--text-3); font-size: 12px;
|
||||
padding: 7px 10px; border-bottom: 1px solid var(--line); white-space: nowrap;
|
||||
}
|
||||
table.tb td { padding: 9px 10px; border-bottom: 1px solid var(--line-soft); }
|
||||
table.tb tr:hover td { background: var(--row-hover); }
|
||||
table.tb td.num, table.tb th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.tw { overflow-x: auto; }
|
||||
/* 表格要给个内容下限:table 是 width:100%,而 CJK 允许逐字断行,没有下限时窄屏会把
|
||||
每列压到一两个字宽(「一般公共场所」会排成 6 行、一行一个字)。有了下限,放不下就
|
||||
交给上面的 .tw 横向滚动,列宽不再被榨干。 */
|
||||
.tw > table.tb { min-width: 640px; }
|
||||
/* 从地点详情页跳进来定位到的那一行(?open=):淡色底,长列表里一眼能认出目标。
|
||||
用底色而不是动画——动画会被 prefers-reduced-motion 跳过,那时就完全看不出来了。 */
|
||||
table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent-soft); }
|
||||
|
||||
/* ---------- 地图 ---------- */
|
||||
#map { height: 100%; width: 100%; background: #eef1f2; }
|
||||
.map-page {
|
||||
position: fixed;
|
||||
inset: calc(var(--nav-h) + var(--safe-t)) var(--safe-r) var(--safe-b) var(--safe-l);
|
||||
}
|
||||
.map-panel {
|
||||
position: absolute; top: 12px; left: 12px; z-index: 400; width: 286px; max-height: calc(100% - 24px);
|
||||
overflow-y: auto; background: var(--glass-strong); backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--line); border-radius: var(--radius); padding: 13px; box-shadow: var(--shadow);
|
||||
}
|
||||
.map-panel h3 { font-size: 13px; margin-bottom: 9px; color: var(--text-2); }
|
||||
.map-legend {
|
||||
/* bottom 让开右下角那行底图版权(约 15px 高),否则两者会叠在一起 */
|
||||
position: absolute; bottom: 26px; right: 12px; z-index: 400; background: var(--glass-strong);
|
||||
border: 1px solid var(--line); border-radius: 8px; padding: 8px 11px; font-size: 12px;
|
||||
max-height: 40%; overflow-y: auto; box-shadow: var(--shadow);
|
||||
}
|
||||
.map-legend .lg { display: flex; align-items: center; gap: 6px; margin-top: 4px; color: var(--text-2); }
|
||||
.map-legend .lg i { width: 9px; height: 9px; border-radius: 50%; }
|
||||
.maplink { display: block; padding: 8px 0; border-bottom: 1px solid var(--line-soft); }
|
||||
.maplink b { font-weight: 500; }
|
||||
.maplink span { color: var(--text-3); font-size: 12px; display: block; }
|
||||
.dot-marker { border-radius: 50%; border: 1.5px solid #fff; box-shadow: 0 0 0 1px rgba(0,0,0,.16); }
|
||||
/* 迷雾浓度的样式从内联挪出来:窄屏要把它收成一个可展开的小标签——
|
||||
不然「图层 / 迷雾浓度 / 缩放」三个控件竖着堆在右下角,占掉近 180px。 */
|
||||
.fog-opacity { background: var(--surface); padding: 6px 8px; line-height: 1.2; }
|
||||
.fog-opacity .fog-label {
|
||||
display: block; text-align: left; border: 0; background: none; padding: 0;
|
||||
font: inherit; font-size: 11px; color: var(--text-2); cursor: default;
|
||||
}
|
||||
.fog-opacity .fog-slider { display: block; width: 96px; margin: 4px 0 0; accent-color: var(--accent); }
|
||||
@media (max-width: 620px) {
|
||||
.fog-opacity .fog-label { cursor: pointer; font-size: 12px; }
|
||||
.fog-opacity .fog-slider { display: none; }
|
||||
.fog-opacity.open .fog-slider { display: block; }
|
||||
}
|
||||
|
||||
/* 长列表划到底后的「回到顶部」。地图页不滚动,所以它不会出现。 */
|
||||
.to-top {
|
||||
position: fixed; right: calc(16px + var(--safe-r)); bottom: calc(20px + var(--safe-b)); z-index: 900;
|
||||
width: 44px; height: 44px; border-radius: 50%; border: 1px solid var(--line);
|
||||
background: var(--panel); color: var(--text-2); font-size: 20px; line-height: 1;
|
||||
cursor: pointer; box-shadow: var(--shadow);
|
||||
}
|
||||
.to-top[hidden] { display: none; }
|
||||
.to-top:hover { color: var(--text); border-color: var(--text-3); }
|
||||
|
||||
/* 时间轴回放时新出现的点:冒出来 + 淡入(配合 JS 里的逐个延时,像"扫过来")。
|
||||
点在 Leaflet 里是 overlay pane 里的 <path>,用 transform-box: fill-box 让缩放以自身
|
||||
中心为原点,才不会跑到左上角去。 */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
@keyframes pt-in {
|
||||
from { opacity: .1; transform: scale(.2); }
|
||||
55% { opacity: 1; }
|
||||
to { opacity: .9; transform: scale(1); }
|
||||
}
|
||||
.leaflet-overlay-pane path.pt-new {
|
||||
animation: pt-in .55s cubic-bezier(.22, .9, .28, 1) both;
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
}
|
||||
/* 聚合圆圈是 DOM 元素、而且 Leaflet 用 transform 给它定位,
|
||||
所以只能动透明度 —— 再加 scale 会把它从原位挪走。 */
|
||||
@keyframes pt-in-dom {
|
||||
from { opacity: .1; }
|
||||
60% { opacity: 1; }
|
||||
to { opacity: .92; }
|
||||
}
|
||||
.leaflet-marker-pane .marker-cluster.pt-new {
|
||||
animation: pt-in-dom .5s ease-out both;
|
||||
}
|
||||
/* 数字变了轻弹一下:弹内层 div(只放数字),外层带 Leaflet 的定位 transform 不能动 */
|
||||
@keyframes pt-pulse {
|
||||
0% { transform: scale(1); }
|
||||
45% { transform: scale(1.3); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
.leaflet-marker-pane .marker-cluster > div.pt-pulse {
|
||||
animation: pt-pulse .32s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
/* 时间轴回放:地图底部中间的一条。窄屏上抬高一档,给右下角的底图版权让位。
|
||||
和左侧的日期区间不是一回事——区间是「只看这段时间」,这里是「走到这一刻为止」。 */
|
||||
.timeline-bar {
|
||||
position: absolute; left: 50%; transform: translateX(-50%);
|
||||
bottom: 12px; z-index: 650;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
max-width: min(560px, calc(100% - 24px));
|
||||
padding: 7px 12px; border-radius: 10px;
|
||||
background: var(--glass-strong); border: 1px solid var(--line); box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.timeline-bar[hidden] { display: none; }
|
||||
.timeline-bar input[type=range] { flex: 1 1 auto; min-width: 90px; accent-color: var(--accent); margin: 0; }
|
||||
.timeline-bar .tl-label { flex: 0 0 auto; font-size: 12px; color: var(--text-3); white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.timeline-bar button {
|
||||
flex: 0 0 auto; border: 1px solid var(--line); background: var(--surface); color: var(--text);
|
||||
border-radius: 7px; cursor: pointer; font: inherit; font-size: 12px; line-height: 1;
|
||||
}
|
||||
.timeline-bar .tl-play { width: 30px; height: 30px; font-size: 13px; padding: 0; }
|
||||
.timeline-bar .tl-reset { padding: 7px 9px; }
|
||||
.timeline-bar button:hover { background: var(--line-soft); }
|
||||
@media (max-width: 620px) {
|
||||
.timeline-bar { bottom: 26px; gap: 8px; padding: 6px 9px; }
|
||||
.timeline-bar .tl-label { font-size: 11.5px; }
|
||||
}
|
||||
|
||||
/* 地图上的状态条(加载失败 + 重试)。放底部中间:左上角是筛选面板、右下是图例与版权。 */
|
||||
.map-status {
|
||||
position: absolute; left: 50%; transform: translateX(-50%);
|
||||
bottom: 62px;
|
||||
z-index: 600; display: flex; align-items: center; gap: 10px;
|
||||
max-width: calc(100% - 24px); background: rgba(30,40,50,.92); color: #fff;
|
||||
border-radius: 8px; padding: 8px 12px; font-size: 13px; box-shadow: var(--shadow);
|
||||
}
|
||||
.map-status[hidden] { display: none; }
|
||||
.map-status .btn { border-color: rgba(255,255,255,.35); background: rgba(255,255,255,.12); color: #fff; flex: 0 0 auto; }
|
||||
.map-status .btn:hover { background: rgba(255,255,255,.22); }
|
||||
@media (max-width: 620px) {
|
||||
.map-panel { left: 8px; right: 8px; width: auto; max-height: 44%; top: 8px; }
|
||||
.map-legend { display: none; }
|
||||
}
|
||||
.map-preview { height: 320px; border-radius: 8px; overflow: hidden; border: 1px solid var(--line); }
|
||||
|
||||
/* ---------- 地点图片 ---------- */
|
||||
/* 管理面板里的缩略图墙 */
|
||||
.imgs-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; }
|
||||
.imgs-cell { position: relative; margin: 0; border-radius: 8px; overflow: hidden; border: 1px solid var(--line); background: var(--line-soft); aspect-ratio: 4 / 3; }
|
||||
.imgs-cell img { width: 100%; height: 100%; object-fit: cover; display: block; cursor: zoom-in; }
|
||||
.imgs-del {
|
||||
position: absolute; top: 5px; right: 5px; width: 22px; height: 22px; border-radius: 50%;
|
||||
border: 0; background: rgba(0,0,0,.55); color: #fff; font-size: 15px; line-height: 1;
|
||||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.imgs-del:hover { background: #b42318; }
|
||||
.imgs-busy { opacity: .45; pointer-events: none; }
|
||||
.imgs-hint { font-size: 12.5px; color: var(--text-3); margin-top: 8px; min-height: 18px; }
|
||||
.imgs-hint.ok { color: #17604f; }
|
||||
.imgs-hint.err { color: #b42318; }
|
||||
/* 存储没配好时把「选择图片」变成不可点的样子,而不是留个能点但必失败的按钮 */
|
||||
.btn.is-disabled { opacity: .5; cursor: not-allowed; }
|
||||
|
||||
/* 展示用:全屏画廊。深色底是为了不干扰照片本身,白底会把照片衬灰。
|
||||
一次只看一张 —— 以前把图竖着堆起来,手机上既不能左右滑也不能放大。 */
|
||||
.imgs-modal { position: fixed; inset: 0; z-index: 2000; background: rgba(12,14,16,.94); overflow: hidden; }
|
||||
.imgs-modal[hidden] { display: none; }
|
||||
.imgs-stage {
|
||||
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
overflow: auto; padding: calc(56px + var(--safe-t)) calc(14px + var(--safe-r)) calc(24px + var(--safe-b)) calc(14px + var(--safe-l));
|
||||
/* 横滑留给我们做切换,纵向留给浏览器;pinch-zoom 让双指放大仍可用系统手势 */
|
||||
touch-action: pan-y pinch-zoom;
|
||||
}
|
||||
.imgs-stage img { display: block; max-width: 100%; max-height: 100%; width: auto; height: auto; border-radius: 8px; }
|
||||
/* 双击放大:图放大到两倍、容器可拖动看局部(用尺寸而不是 transform,放大后不糊) */
|
||||
.imgs-stage.zoomed { touch-action: pan-x pan-y; align-items: flex-start; justify-content: flex-start; }
|
||||
.imgs-stage.zoomed img { max-width: none; max-height: none; width: 200%; border-radius: 0; }
|
||||
.imgs-close {
|
||||
position: fixed; top: calc(12px + var(--safe-t)); right: calc(14px + var(--safe-r)); z-index: 2001;
|
||||
width: 44px; height: 44px; border-radius: 50%;
|
||||
border: 1px solid rgba(255,255,255,.3); background: rgba(0,0,0,.4); color: #fff; font-size: 22px;
|
||||
line-height: 1; cursor: pointer;
|
||||
}
|
||||
.imgs-close:hover { background: rgba(255,255,255,.18); }
|
||||
.imgs-count {
|
||||
position: fixed; top: calc(20px + var(--safe-t)); left: calc(18px + var(--safe-l)); z-index: 2001;
|
||||
color: rgba(255,255,255,.78); font-size: 13px;
|
||||
}
|
||||
/* 上一张 / 下一张:做成整条竖带,拇指够得着,不用瞄小按钮 */
|
||||
.imgs-nav {
|
||||
position: fixed; top: 50%; transform: translateY(-50%); z-index: 2001;
|
||||
width: 44px; height: 76px; border: 0; border-radius: 10px; cursor: pointer;
|
||||
background: rgba(255,255,255,.12); color: #fff; font-size: 30px; line-height: 1;
|
||||
}
|
||||
.imgs-nav:hover { background: rgba(255,255,255,.24); }
|
||||
.imgs-prev { left: calc(6px + var(--safe-l)); }
|
||||
.imgs-next { right: calc(6px + var(--safe-r)); }
|
||||
|
||||
/* ---------- 「停一下」提示页(内容对访客不开放之类) ---------- */
|
||||
.notice-body {
|
||||
min-height: 100vh; margin: 0; display: flex; align-items: center; justify-content: center;
|
||||
padding: 24px 18px; background: var(--bg);
|
||||
}
|
||||
.notice-card {
|
||||
max-width: 420px; text-align: center; background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: var(--radius); padding: 30px 26px; box-shadow: var(--shadow);
|
||||
}
|
||||
.notice-card .notice-ico { font-size: 40px; line-height: 1; margin-bottom: 12px; }
|
||||
.notice-card h1 { font-size: 18px; margin: 0 0 8px; }
|
||||
.notice-card p { color: var(--text-2); font-size: 13.5px; margin: 0 0 18px; }
|
||||
.notice-card .notice-tip { color: var(--text-3); font-size: 12px; margin-top: 12px; }
|
||||
|
||||
/* 后台设置页的「标签 / 值」信息行(地图底图、图片存储状态)。
|
||||
这个 class 一直没写样式,之前是两段光秃秃的 inline 文本;长一点的 URL
|
||||
(比如对象存储的 endpoint)会把整页撑宽 —— 所以要 minmax(0,1fr) + 允许断词。 */
|
||||
.kv { display: grid; grid-template-columns: 88px minmax(0, 1fr); gap: 4px 12px; font-size: 13px; }
|
||||
.kv .k { color: var(--text-3); }
|
||||
.kv .v { color: var(--text); overflow-wrap: anywhere; }
|
||||
@media (max-width: 620px) {
|
||||
.kv { grid-template-columns: 1fr; gap: 2px; }
|
||||
.kv .k { font-size: 12px; }
|
||||
}
|
||||
/* URL 这类超长 token 不该把容器顶宽 */
|
||||
.mono { overflow-wrap: anywhere; }
|
||||
|
||||
/* ---------- 暗色模式 ----------
|
||||
跟随系统(prefers-color-scheme)。配色本来就是变量驱动的,所以这里主要换一套变量,
|
||||
再覆盖几处硬编码的白色面。地图瓦片保持浅色——上游只有这一套免密钥瓦片,
|
||||
所以地图页只暗掉面板与控件,瓦片不动。
|
||||
色值是算过的:主文字 13.6:1、次要 8.0:1、弱化 5.4:1(都在 AA 之上)。 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
/* 用 html:root 而不是 :root:模板会在 CSS 之后注入一条 :root{--accent:…}(站长自定义主色),
|
||||
同特异性下后者胜出,所以这里必须高一级才盖得住。 */
|
||||
html:root {
|
||||
/* 站长选的主色原值在深底上往往太暗(深绿只有 3.3:1),按比例掺白提亮 */
|
||||
--accent: color-mix(in srgb, var(--accent-base, #2f7d6e) 58%, #ffffff);
|
||||
--accent-soft: color-mix(in srgb, var(--accent-base, #2f7d6e) 24%, #1c2024);
|
||||
--bg: #14171a;
|
||||
--panel: #1c2024;
|
||||
--line: #2b3137;
|
||||
--line-soft: #23272c;
|
||||
--text: #e8eaed;
|
||||
--text-2: #b0b6bd;
|
||||
--text-3: #99a0a8;
|
||||
--surface: #22262b;
|
||||
--glass: rgba(28, 32, 36, .92);
|
||||
--glass-strong: rgba(28, 32, 36, .96);
|
||||
--row-hover: #212529;
|
||||
--bg-2: #191d21;
|
||||
/* 浅绿底上用白字只有 2.4:1,反过来用深字才够 */
|
||||
--on-accent: #0f1b18;
|
||||
--ok-bg: #16302a; --ok-fg: #7fd3bb; --ok-line: #245046;
|
||||
--err-bg: #33201e; --err-fg: #f0a49b; --err-line: #5a3330;
|
||||
--info-bg: #1b2534; --info-fg: #9dbdea; --info-line: #2f4055;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, .45), 0 1px 3px rgba(0, 0, 0, .35);
|
||||
}
|
||||
/* 兜底:还有两处没走变量的白底 */
|
||||
.flatpickr-calendar { background: var(--panel); border-color: var(--line); box-shadow: var(--shadow); }
|
||||
.flatpickr-day { color: var(--text); }
|
||||
.flatpickr-day.prevMonthDay, .flatpickr-day.nextMonthDay { color: var(--text-3); }
|
||||
.flatpickr-day:hover { background: var(--line-soft); color: var(--text); }
|
||||
.flatpickr-months .flatpickr-month, .flatpickr-weekdays, .flatpickr-weekday { color: var(--text-2); fill: var(--text-2); background: transparent; }
|
||||
.flatpickr-current-month .flatpickr-monthDropdown-months { background: var(--panel); color: var(--text); }
|
||||
.flatpickr-time input, .flatpickr-time .flatpickr-am-pm { color: var(--text); }
|
||||
/* Leaflet 自带的控件是白底,暗色下要跟着换,否则浅字压浅底看不清。
|
||||
注意 leaflet.css 里角标的背景写的是 `.leaflet-container .leaflet-control-attribution`
|
||||
(两级),所以这里必须带上 .leaflet-container 前缀,否则盖不住。 */
|
||||
.leaflet-container .leaflet-bar a,
|
||||
.leaflet-container .leaflet-bar a:hover,
|
||||
.leaflet-container .leaflet-control-layers,
|
||||
.leaflet-container .leaflet-control-layers-expanded,
|
||||
.leaflet-container .leaflet-control-attribution,
|
||||
.fog-opacity {
|
||||
background: var(--surface); color: var(--text);
|
||||
}
|
||||
.leaflet-container .leaflet-bar a { border-bottom-color: var(--line); }
|
||||
.leaflet-container .leaflet-bar a:hover { background: var(--line-soft); }
|
||||
.leaflet-container .leaflet-control-attribution,
|
||||
.leaflet-container .leaflet-control-attribution a { color: var(--text-2); }
|
||||
.leaflet-control-scale-line {
|
||||
background: rgba(28, 32, 36, .7); border-color: var(--text-3); color: var(--text-2);
|
||||
}
|
||||
.leaflet-container { background: #101315; }
|
||||
/* 地图上的浅色瓦片在暗色 UI 里反差过大,整体压一点点亮度 */
|
||||
#map .leaflet-tile-pane { filter: brightness(.86) saturate(.9); }
|
||||
}
|
||||
|
||||
/* ---------- 键盘焦点 ----------
|
||||
全站原来没有任何 :focus-visible 规则:用 Tab 走查时看不出焦点落在哪,
|
||||
键盘用户(以及用读屏的)基本没法用。只在键盘操作时出现,鼠标点击不会看到这个圈。 */
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.btn:focus-visible, .chip:focus-visible, .vote-btn:focus-visible,
|
||||
.nav-links a:focus-visible, .pager a:focus-visible, .imgs-nav:focus-visible, .to-top:focus-visible {
|
||||
outline: 2px solid var(--accent); outline-offset: 2px;
|
||||
}
|
||||
/* 输入框本来就有 focus 描边,这里只补键盘态的对比度 */
|
||||
input:focus-visible, select:focus-visible, textarea:focus-visible, summary:focus-visible {
|
||||
outline: 2px solid var(--accent); outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* ---------- 顶部加载进度条 ---------- */
|
||||
.load-bar {
|
||||
position: fixed; top: 0; left: 0; height: 2px; width: 0; z-index: 3000;
|
||||
background: var(--accent); opacity: 0;
|
||||
transition: width .18s ease-out, opacity .26s ease;
|
||||
}
|
||||
.load-bar[hidden] { display: none; }
|
||||
|
||||
/* ---------- 「已选条件」行 ---------- */
|
||||
.active-filters { margin-bottom: 12px; align-items: center; }
|
||||
.active-filters .af-label { font-size: 12.5px; color: var(--text-3); }
|
||||
.active-filters .chip { user-select: none; }
|
||||
.active-filters .af-x { margin-left: 4px; opacity: .6; font-size: 13px; }
|
||||
.active-filters .chip:hover .af-x { opacity: 1; }
|
||||
.active-filters .af-clear { background: transparent; color: var(--text-3); border-color: var(--line); }
|
||||
|
||||
/* ---------- 其他 ---------- */
|
||||
.alert { padding: 9px 13px; border-radius: 8px; font-size: 13px; margin-bottom: 14px; }
|
||||
.alert.err { background: var(--err-bg); color: var(--err-fg); border: 1px solid var(--err-line); }
|
||||
.alert.ok { background: var(--ok-bg); color: var(--ok-fg); border: 1px solid var(--ok-line); }
|
||||
.alert.info { background: var(--info-bg); color: var(--info-fg); border: 1px solid var(--info-line); }
|
||||
.muted { color: var(--text-3); }
|
||||
.mono { font-family: var(--mono); font-size: 12px; }
|
||||
.empty { text-align: center; padding: 40px 16px; color: var(--text-3); }
|
||||
.empty .big { font-size: 30px; margin-bottom: 8px; }
|
||||
.pager { display: flex; gap: 6px; align-items: center; justify-content: center; margin-top: 18px; font-size: 13px; }
|
||||
.pager a, .pager span { padding: 4px 10px; border-radius: 6px; border: 1px solid var(--line); color: var(--text-2); }
|
||||
.pager a:hover { background: var(--line-soft); text-decoration: none; }
|
||||
.pager .cur { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||
footer.ft { max-width: 1120px; margin: 0 auto; padding: 20px; color: var(--text-3); font-size: 12.5px; text-align: center; }
|
||||
.timeline-day { margin-bottom: 20px; }
|
||||
.timeline-day .dh {
|
||||
position: sticky; top: 54px; background: var(--bg); z-index: 5; padding: 6px 0 7px;
|
||||
font-size: 13px; color: var(--text-2); font-weight: 500; display: flex; gap: 8px; align-items: baseline;
|
||||
}
|
||||
.timeline-day .dh .muted { font-size: 12px; }
|
||||
.tl-item { display: grid; grid-template-columns: 62px 20px 1fr; gap: 10px; align-items: start; padding: 7px 0; }
|
||||
.tl-item .tm { color: var(--text-3); font-size: 12.5px; font-variant-numeric: tabular-nums; padding-top: 1px; }
|
||||
.tl-item .ln { display: flex; justify-content: center; position: relative; }
|
||||
.tl-item .ln::before { content: ""; position: absolute; top: 12px; bottom: -12px; width: 1px; background: var(--line); }
|
||||
.tl-item:last-child .ln::before { display: none; }
|
||||
.tl-item .ln i { width: 9px; height: 9px; border-radius: 50%; background: var(--accent); margin-top: 5px; position: relative; z-index: 1; }
|
||||
.tl-item .bd { font-size: 13.5px; }
|
||||
.tl-item .bd .meta { color: var(--text-3); font-size: 12.5px; }
|
||||
|
||||
/* 聚合气泡改用主题色,默认的黄蓝配色和站点风格不一致 */
|
||||
.marker-cluster-small,
|
||||
.marker-cluster-medium,
|
||||
.marker-cluster-large { background-color: color-mix(in srgb, var(--accent) 22%, transparent); }
|
||||
.marker-cluster-small div,
|
||||
.marker-cluster-medium div,
|
||||
.marker-cluster-large div { background-color: var(--accent); color: var(--on-accent); font-weight: 500; }
|
||||
|
||||
/* 筛选面板占据左上角,把缩放控件挪到右上与图层切换并列 */
|
||||
.map-panel { left: 12px; }
|
||||
@media (max-width: 620px) { .map-panel { left: 8px; right: 8px; } }
|
||||
|
||||
/* 更多筛选类型 */
|
||||
.more-types { margin: 2px 0 10px; }
|
||||
.more-types summary { font-size: 12.5px; color: var(--text-3); cursor: pointer; padding: 3px 0; }
|
||||
.more-types summary:hover { color: var(--accent); }
|
||||
.more-types .chips { margin-top: 8px; }
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.leaflet-bottom.leaflet-right .leaflet-control-zoom { margin-bottom: 34px; }
|
||||
}
|
||||
|
||||
/* 推荐 / 踩雷 表态按钮 */
|
||||
.vote-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
|
||||
.vote-label { color: var(--text-3); font-size: 12.5px; min-width: 62px; }
|
||||
/* 「推荐 / 踩雷」必须并排:把它们包成不可换行的组,窄屏时只让后面那句提示换行 */
|
||||
.vote-btns { display: inline-flex; gap: 8px; flex-wrap: nowrap; }
|
||||
.vote-btn {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 6px 14px; border-radius: 999px; white-space: nowrap; flex: 0 0 auto;
|
||||
border: 1px solid var(--line); background: var(--surface); color: var(--text);
|
||||
font-size: 13.5px; font-weight: 500; cursor: pointer; line-height: 1;
|
||||
transition: background .12s, border-color .12s, color .12s;
|
||||
}
|
||||
.vote-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.vote-btn.up.on { background: #e7f6ec; border-color: #34c759; color: #1f8f43; }
|
||||
.vote-btn.down.on { background: #fdeceb; border-color: #ff3b30; color: #c62a20; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
/* 浅底深字在深色页里太扎眼,换成深底亮字(对比度都在 AA 之上) */
|
||||
.vote-btn.up.on { background: #16302a; border-color: #34c759; color: #7fd3bb; }
|
||||
.vote-btn.down.on { background: #33201e; border-color: #ff3b30; color: #f0a49b; }
|
||||
.vote-tip.up { color: #7fd3bb; }
|
||||
.vote-tip.down { color: #f0a49b; }
|
||||
/* 深红字在深底上只有 2.3:1,换成亮红 */
|
||||
.btn.danger { color: var(--err-fg); border-color: var(--err-line); }
|
||||
}
|
||||
.vote-btn:disabled { opacity: .55; cursor: default; }
|
||||
.vote-tip { font-size: 12.5px; color: var(--text-3); }
|
||||
.vote-tip.up { color: #1f8f43; }
|
||||
.vote-tip.down { color: #c62a20; }
|
||||
/* 站长页面上访客票数只读展示:沿用「我的结论」同一套行结构,尺寸/行距完全一致,只弱化配色 */
|
||||
.vote-btn.ro {
|
||||
cursor: default; color: var(--text-2);
|
||||
border-style: dashed; border-color: var(--line); background: transparent;
|
||||
}
|
||||
.vote-btn.ro:hover { border-color: var(--line); color: var(--text-2); }
|
||||
/* 列表 / 气泡里的推荐踩雷小标签 */
|
||||
/* 坐标疑似没做过 GCJ-02 偏移的提醒标 */
|
||||
.tag.warn { background: #fdf3e3; color: #a15c00; }
|
||||
.tag.vote-up { background: #e7f6ec; color: #1f8f43; }
|
||||
.tag.vote-down { background: #fdeceb; color: #c62a20; }
|
||||
|
||||
/* 地图气泡:地点信息 + 只读备注 + 就地表决 */
|
||||
.mp { min-width: 240px; max-width: 300px; }
|
||||
.mp-n { font-weight: 600; margin-bottom: 2px; }
|
||||
.mp-m { color: var(--text-3); font-size: 12px; line-height: 1.6; }
|
||||
.mp-note {
|
||||
margin-top: 6px; padding: 6px 8px; border-radius: 6px;
|
||||
background: var(--bg, #f5f6f8); color: var(--text);
|
||||
font-size: 12.5px; line-height: 1.55; white-space: pre-line;
|
||||
max-height: 150px; overflow-y: auto;
|
||||
}
|
||||
.mp-foot { margin-top: 7px; font-size: 12.5px; }
|
||||
/* nowrap:气泡本来就窄,「👎 踩雷」被挤到第二行会很难看 */
|
||||
.mp-vote { display: flex; align-items: center; gap: 6px; margin-top: 8px; flex-wrap: nowrap; }
|
||||
.mp-vl { flex: 0 0 auto; color: var(--text-3); font-size: 12px; white-space: nowrap; }
|
||||
.mp-vb {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 4px 10px; border-radius: 999px;
|
||||
border: 1px solid var(--line); background: var(--surface); color: var(--text);
|
||||
font-size: 12.5px; font-weight: 500; line-height: 1; cursor: pointer;
|
||||
flex: 0 0 auto; white-space: nowrap;
|
||||
transition: background .12s, border-color .12s, color .12s;
|
||||
}
|
||||
.mp-vb b { font-weight: 600; }
|
||||
.mp-vb:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.mp-vb.up.on { background: #e7f6ec; border-color: #34c759; color: #1f8f43; }
|
||||
.mp-vb.down.on { background: #fdeceb; border-color: #ff3b30; color: #c62a20; }
|
||||
.mp-vb:disabled { opacity: .55; cursor: default; }
|
||||
|
||||
/* 备注块:rond 回填的备注是按次一行的,必须保留换行 */
|
||||
.note-block {
|
||||
margin-top: 4px; padding: 7px 9px; border-radius: 8px;
|
||||
background: var(--bg, #f5f6f8); color: var(--text-2);
|
||||
font-size: 12.8px; line-height: 1.65; white-space: pre-line;
|
||||
}
|
||||
|
||||
/* 报告导出为 PDF / 长图:隐藏导航、页脚与操作按钮,卡片避免跨页断裂 */
|
||||
@media print {
|
||||
.nav, .ft, .no-print { display: none !important; }
|
||||
body { background: #fff; }
|
||||
.wrap { max-width: 820px; margin: 0 auto; padding: 0 12px; }
|
||||
.page-head h1 { margin-top: 8px; }
|
||||
.card, .kpi, .kpis { break-inside: avoid; }
|
||||
.grid.g2 { grid-template-columns: 1fr 1fr; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
.bar-fill { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.chip.on { background: var(--accent-soft); color: var(--accent); }
|
||||
}
|
||||
|
||||
|
||||
/* 数据编辑页:行内编辑用 details 折叠,summary 复用 .btn 外观 */
|
||||
.inline-edit { display: inline-block; position: relative; }
|
||||
.inline-edit summary { list-style: none; display: inline-block; }
|
||||
.inline-edit summary::-webkit-details-marker { display: none; }
|
||||
.inline-edit[open] { display: block; }
|
||||
.inline-edit form { background: var(--bg-2, #fafbfb); border: 1px solid var(--line); border-radius: 10px; padding: 12px; }
|
||||
.inline-edit form .f { margin-bottom: 8px; }
|
||||
|
||||
/* 「按名称搜坐标」输入框 + 按钮:必须始终同一行(含移动端),底边对齐。
|
||||
* 不能直接复用 .row —— label.f 自带 margin-bottom,flex-end 按外边距盒对齐会把它顶高;
|
||||
* 且 .row 会换行。这里显式 nowrap + 归零外边距,并让输入框可收缩。 */
|
||||
.search-row { display: flex; flex-wrap: nowrap; align-items: flex-end; gap: 8px; margin-bottom: 12px; }
|
||||
.search-row > label.f { flex: 1 1 auto; min-width: 0; margin-bottom: 0; }
|
||||
.search-row .btn { flex: 0 0 auto; font-size: 14px; }
|
||||
.search-row > .act { display: flex; flex: 0 0 auto; gap: 8px; }
|
||||
|
||||
/* 按名称搜坐标的候选列表:整行可点,名称与地址分两行 */
|
||||
.geo-list { display: flex; flex-direction: column; gap: 4px; margin: 0 0 12px; }
|
||||
/* 到访选择器的候选列表有 max-height(200~240px),但 flex 子项不会因为上限而收缩、
|
||||
overflow 又是 visible —— 30 条候选会实打实溢出上千像素,被后面的表单元素盖住。
|
||||
必须给个 overflow 让它在自己的框里滚。 */
|
||||
.picker-results { overflow-y: auto; }
|
||||
.geo-list:empty { margin: 0; }
|
||||
/* 行程列表的路线列:窄屏下每列只剩几十像素,城市名会把行高撑到到访列表的近两倍,
|
||||
藏掉它(编号 + 地点名够认了,城市在紧邻的到访列表里就有),title 里仍留着全称。 */
|
||||
@media (max-width: 620px) { .mv-city { display: none; } }
|
||||
|
||||
/* 筛选行:和 .row 一样窄屏会换行,但控件按底边对齐。
|
||||
不能直接用 .row + align-items:flex-end——label.f 自带 margin-bottom,
|
||||
flex-end 按外边距盒对齐会把按钮顶低几像素(就是那种「按钮有点歪」)。 */
|
||||
.filter-row { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; }
|
||||
.filter-row > label.f { margin-bottom: 0; }
|
||||
.filter-row > .act { display: flex; flex: 0 0 auto; gap: 8px; }
|
||||
.geo-item {
|
||||
display: block; width: 100%; text-align: left; cursor: pointer;
|
||||
padding: 7px 10px; border: 1px solid var(--line); border-radius: 8px;
|
||||
background: var(--panel); color: var(--text); font: inherit;
|
||||
}
|
||||
.geo-item:hover { background: var(--line-soft); }
|
||||
.geo-name { display: block; font-size: 13.5px; }
|
||||
.geo-addr { display: block; color: var(--text-3); font-size: 12px; margin-top: 2px; }
|
||||
|
||||
/* 「同时创建到访」折叠区 */
|
||||
.opt-visit { margin: 0 0 12px; }
|
||||
.opt-visit > summary { cursor: pointer; color: var(--text-3); font-size: 12.5px; margin-bottom: 8px; }
|
||||
.opt-visit .row { margin-bottom: 8px; }
|
||||
|
||||
/* ---------- 专题 ---------- */
|
||||
/* 必须带上 #map:站点里 #map{height:100%} 是 ID 选择器,
|
||||
单靠 .topic-map 这个类压不过它,容器会被算成 0 高(地图看不见)。 */
|
||||
#map.topic-map { height: 520px; width: 100%; border-radius: 10px; overflow: hidden; }
|
||||
@media (max-width: 620px) { #map.topic-map { height: 360px; } }
|
||||
.topic-card { display: block; text-decoration: none; color: inherit; transition: box-shadow .15s, transform .15s; }
|
||||
.topic-card:hover { box-shadow: 0 6px 22px rgba(0,0,0,.08); transform: translateY(-1px); }
|
||||
.topic-head { display: flex; align-items: center; gap: 8px; }
|
||||
.topic-head h2 { margin: 0; }
|
||||
.topic-card .kpis { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
@media (max-width: 620px) { .topic-card .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
|
||||
/* 地图兜底提示:脚本没就绪时留在容器里,解释为什么没有地图 */
|
||||
.map-fallback {
|
||||
display: flex; align-items: center; justify-content: center; height: 100%;
|
||||
padding: 20px; text-align: center; color: var(--text-3); font-size: 13px;
|
||||
}
|
||||
|
||||
/* 专题:时间范围独立成行(精确到分钟,塞进 KPI 网格会被挤成多行) */
|
||||
.topic-range { margin-top: 5px; color: var(--text-2); font-size: 13.5px; font-variant-numeric: tabular-nums; }
|
||||
.topic-card .topic-range { margin: 7px 0 0; }
|
||||
.topic-desc { color: var(--text-2); white-space: pre-wrap; line-height: 1.65; }
|
||||
.kpis.kpis-3 .kpi { flex: 1 1 30%; }
|
||||
@media (max-width: 620px) {
|
||||
/* 三个数字都很短,窄屏保持三列比 2+1 更好看 */
|
||||
.topic-card .kpis.kpis-3 .kpi { flex: 1 1 30%; }
|
||||
.topic-card .kpi { padding: 11px 12px; }
|
||||
.topic-range { font-size: 12.5px; }
|
||||
}
|
||||
|
||||
/* ---------- 表单里的「文字 + 内联按钮」组合 ---------- */
|
||||
/* label 的文字行右侧放次要操作(活动改名/删除),不额外占高度,也就不会把整格撑高 */
|
||||
label.f > span.span-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.mini-actions { display: flex; gap: 8px; font-size: 12px; }
|
||||
.mini-actions a { color: var(--text-3); }
|
||||
.mini-actions a:hover { color: var(--accent); }
|
||||
.mini-actions a.danger:hover { color: #b42318; }
|
||||
/* 输入框 + 按钮同一行:input 自适应、按钮不压缩(不设 min-width:0 的话 input 会被按钮顶到下一行) */
|
||||
.input-btn { display: flex; gap: 6px; }
|
||||
.input-btn > input { flex: 1 1 auto; min-width: 0; }
|
||||
.input-btn > .btn { flex: 0 0 auto; padding-left: 12px; padding-right: 12px; }
|
||||
|
||||
/* 到访列表里的收藏星标:点一下就切换,不用展开行内编辑 */
|
||||
.star {
|
||||
background: none; border: 0; padding: 0 3px; cursor: pointer; line-height: 1;
|
||||
font-size: 15px; color: var(--text-3); vertical-align: -1px;
|
||||
}
|
||||
.star:hover { color: #e8b700; }
|
||||
.star.on { color: #e8b700; }
|
||||
|
||||
/* 单个点位的地图弹窗 */
|
||||
#point-modal {
|
||||
position: fixed; inset: 0; z-index: 1200; display: flex; align-items: flex-start;
|
||||
justify-content: center; padding: 40px 16px; overflow: auto;
|
||||
background: rgba(0, 0, 0, .45);
|
||||
}
|
||||
.point-modal-box {
|
||||
width: 100%; max-width: 660px; background: var(--panel); border-radius: 12px;
|
||||
padding: 14px; box-shadow: 0 12px 40px rgba(0, 0, 0, .25);
|
||||
}
|
||||
.point-modal-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
|
||||
.point-modal-map { height: 360px; border-radius: 10px; overflow: hidden; }
|
||||
@media (max-width: 620px) {
|
||||
#point-modal { padding: 16px 10px; }
|
||||
.point-modal-map { height: 260px; }
|
||||
}
|
||||
|
||||
/* 表格里的行内操作链接不许折行:中文默认能在任意字间断开,
|
||||
「地图」「编辑」这类两字链接在窄列里会被拆成两行 */
|
||||
.tb a { white-space: nowrap; }
|
||||
|
||||
/* 照片导入表:固定列宽比例,输入框跟着列宽走。
|
||||
否则每个 input 自带 min-width,8 列加起来必然超出视口,最右边的「说明」列要横向拖动才看得到 */
|
||||
.photo-table { table-layout: fixed; width: 100%; min-width: 880px; }
|
||||
.photo-table th, .photo-table td { padding: 7px 8px; vertical-align: top; }
|
||||
.photo-table input, .photo-table select { width: 100%; min-width: 0; box-sizing: border-box; }
|
||||
.photo-table input[type=datetime-local] { font-size: 12.5px; padding: 6px 6px; }
|
||||
.photo-table input[type=checkbox] { width: auto; }
|
||||
.photo-table .chip { white-space: nowrap; }
|
||||
|
||||
/* 窄屏:9 列在手机上放不下,把信息量最低的「文件」列去掉(地点名已经能认出是哪张),
|
||||
同时把最小宽度调小,需要横向滑动时也能更快看到最右边的操作 */
|
||||
@media (max-width: 700px) {
|
||||
.photo-table { min-width: 640px; }
|
||||
.photo-table col:nth-child(3),
|
||||
.photo-table thead th:nth-child(3),
|
||||
.photo-table tbody tr td:nth-child(3) { display: none; }
|
||||
}
|
||||
|
||||
/* 照片导入:确认表上方的总览图。
|
||||
做成「粘」的是为了让「鼠标划到某一行 → 图上那个点跳出来」始终看得见——
|
||||
表格几十行,图一滚出屏幕这个联动就白搭了。粘住时必须自带不透明底,
|
||||
否则下面滚过去的行会透上来。 */
|
||||
#map-card {
|
||||
position: sticky; top: calc(var(--nav-h) + 8px); z-index: 5;
|
||||
background: var(--panel); padding: 2px 0 10px; margin-bottom: 2px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
.photo-map {
|
||||
height: 200px; width: 100%; border-radius: 10px; overflow: hidden;
|
||||
border: 1px solid var(--line); transition: height .18s ease;
|
||||
}
|
||||
/* 粘住以后收矮:200px 的浮层会挡住从下面滚过去的半个屏幕,收到 132px 还能看清
|
||||
高亮出来的那个点,占的高度也只剩一条。说明文字这时候就没必要留着了。 */
|
||||
#map-card.stuck .photo-map { height: 132px; }
|
||||
#map-card.stuck .card-sub { display: none; }
|
||||
#map-card.stuck { box-shadow: 0 6px 14px rgba(16, 24, 40, .07); }
|
||||
@media (max-width: 700px) {
|
||||
.photo-map { height: 170px; }
|
||||
#map-card.stuck .photo-map { height: 110px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.photo-map { transition: none; }
|
||||
}
|
||||
|
||||
/* 照片导入的进度/结果提示:脚本直接往元素里写文本,空的时候自己收起 */
|
||||
#parse-status { margin: 12px 0 0; }
|
||||
#parse-status:empty { display: none; }
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="8" fill="#2f7d6e"/>
|
||||
<path d="M16 6c-4.4 0-8 3.6-8 8 0 5.6 8 12 8 12s8-6.4 8-12c0-4.4-3.6-8-8-8z" fill="#fff"/>
|
||||
<circle cx="16" cy="14" r="3.2" fill="#2f7d6e"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 267 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,278 @@
|
||||
// 浏览器端读取照片的 EXIF(拍摄时间 + GPS),只读文件头附近的一小段,
|
||||
// 不把整张照片传到服务器——几十张手机照片动辄几百 MB,上传既慢又占磁盘。
|
||||
//
|
||||
// 支持的容器:JPEG(APP1 段)与 HEIC/HEIF(ISO BMFF 的 meta box)。
|
||||
// 算法与 internal/exif 的 Go 实现一致,但这里必须分片读取:
|
||||
// JPEG 的 APP1 在最前面,通常 < 64KB;
|
||||
// HEIC 的 Exif item 位置由 iloc 指定(绝对偏移),可能落在文件靠后,
|
||||
// 所以先读 64KB 解析出偏移,必要时再按需读那一小段。
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var HEAD_BYTES = 64 * 1024;
|
||||
|
||||
function readRange(file, start, end) {
|
||||
var blob = file.slice(start, Math.min(end, file.size));
|
||||
return blob.arrayBuffer().then(function (buf) {
|
||||
return new Uint8Array(buf);
|
||||
});
|
||||
}
|
||||
|
||||
function str(u8, off, len) {
|
||||
var s = '';
|
||||
for (var i = 0; i < len && off + i < u8.length; i++) s += String.fromCharCode(u8[off + i]);
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---------- TIFF(JPEG 与 HEIC 共用,HEIC 里是 Exif item 的负载) ----------
|
||||
|
||||
function readIFD(dv, u8, base, off, le) {
|
||||
var out = {};
|
||||
if (off < 0 || base + off + 2 > u8.length) return out;
|
||||
var n = dv.getUint16(base + off, le);
|
||||
var p0 = base + off + 2;
|
||||
if (p0 + n * 12 > u8.length) n = Math.max(0, Math.floor((u8.length - p0) / 12));
|
||||
for (var i = 0; i < n; i++) {
|
||||
var p = p0 + i * 12;
|
||||
out[dv.getUint16(p, le)] = {
|
||||
type: dv.getUint16(p + 2, le),
|
||||
count: dv.getUint32(p + 4, le),
|
||||
valOff: p + 8
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function typeSize(t) {
|
||||
switch (t) {
|
||||
case 1: case 2: case 6: case 7: return 1;
|
||||
case 3: case 8: return 2;
|
||||
case 4: case 9: case 11: return 4;
|
||||
case 5: case 10: case 12: return 8;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ascii 读 ASCII 值:总长 ≤ 4 字节时内联在条目里,否则 valOff 是偏移
|
||||
function ascii(dv, u8, base, e, le) {
|
||||
var total = e.count;
|
||||
if (total <= 0) return '';
|
||||
var start;
|
||||
if (total <= 4) {
|
||||
start = e.valOff;
|
||||
} else {
|
||||
start = base + dv.getUint32(e.valOff, le);
|
||||
}
|
||||
var out = '';
|
||||
for (var i = 0; i < total && start + i < u8.length; i++) {
|
||||
var c = u8[start + i];
|
||||
if (c === 0) break;
|
||||
out += String.fromCharCode(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function rationals(dv, u8, base, e, le, n) {
|
||||
var off = base + dv.getUint32(e.valOff, le);
|
||||
if (off < 0 || off + n * 8 > u8.length) return null;
|
||||
var out = [];
|
||||
for (var i = 0; i < n; i++) {
|
||||
var num = dv.getUint32(off + i * 8, le);
|
||||
var den = dv.getUint32(off + i * 8 + 4, le);
|
||||
if (den === 0) return null;
|
||||
out.push(num / den);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// gpsCoord 把「度分秒 + 半球」拼成十进制度
|
||||
function gpsCoord(dv, u8, base, le, gps, refTag, valTag) {
|
||||
var refE = gps[refTag], valE = gps[valTag];
|
||||
if (!refE || !valE) return null;
|
||||
var parts = rationals(dv, u8, base, valE, le, 3);
|
||||
if (!parts) return null;
|
||||
var v = parts[0] + parts[1] / 60 + parts[2] / 3600;
|
||||
var ref = ascii(dv, u8, base, refE, le).toUpperCase();
|
||||
if (ref === 'S' || ref === 'W') v = -v;
|
||||
return v;
|
||||
}
|
||||
|
||||
function parseTIFF(u8, base) {
|
||||
var dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
|
||||
if (base + 8 > u8.length) throw new Error('EXIF 数据不完整');
|
||||
var mark = str(u8, base, 2);
|
||||
if (mark !== 'II' && mark !== 'MM') throw new Error('EXIF 标识不对');
|
||||
var le = mark === 'II';
|
||||
if (dv.getUint16(base + 2, le) !== 0x002A) throw new Error('EXIF 标识不对');
|
||||
|
||||
var res = { hasGPS: false, hasTime: false, lat: 0, lon: 0, shot: null };
|
||||
var ifd0 = readIFD(dv, u8, base, dv.getUint32(base + 4, le), le);
|
||||
|
||||
if (ifd0[0x8769]) {
|
||||
var sub = readIFD(dv, u8, base, dv.getUint32(ifd0[0x8769].valOff, le), le);
|
||||
if (sub[0x9003]) {
|
||||
var m = /^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/.exec(ascii(dv, u8, base, sub[0x9003], le).trim());
|
||||
if (m) {
|
||||
res.shot = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]);
|
||||
res.hasTime = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ifd0[0x8825]) {
|
||||
var gps = readIFD(dv, u8, base, dv.getUint32(ifd0[0x8825].valOff, le), le);
|
||||
var la = gpsCoord(dv, u8, base, le, gps, 1, 2);
|
||||
var lo = gpsCoord(dv, u8, base, le, gps, 3, 4);
|
||||
if (la !== null && lo !== null && (la !== 0 || lo !== 0)) {
|
||||
res.lat = la; res.lon = lo; res.hasGPS = true;
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// ---------- JPEG ----------
|
||||
|
||||
function parseJPEG(u8) {
|
||||
var dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
|
||||
var i = 2;
|
||||
while (i + 4 <= u8.length) {
|
||||
if (u8[i] !== 0xFF) { i++; continue; }
|
||||
var marker = u8[i + 1];
|
||||
if (marker === 0xD8 || marker === 0x01 || (marker >= 0xD0 && marker <= 0xD7)) { i += 2; continue; }
|
||||
if (marker === 0xDA || marker === 0xD9) break; // 图像数据开始
|
||||
var size = (u8[i + 2] << 8) | u8[i + 3];
|
||||
if (size < 2 || i + 2 + size > u8.length) break;
|
||||
if (marker === 0xE1 && size > 8 && str(u8, i + 4, 6) === 'Exif\x00\x00') {
|
||||
return parseTIFF(u8, i + 10);
|
||||
}
|
||||
i += 2 + size;
|
||||
}
|
||||
throw new Error('这张 JPEG 里没有 EXIF 信息');
|
||||
}
|
||||
|
||||
// ---------- HEIC / HEIF ----------
|
||||
|
||||
function boxHeader(dv, u8, off) {
|
||||
if (off + 8 > u8.length) return null;
|
||||
var size = dv.getUint32(off);
|
||||
var hdr = 8;
|
||||
if (size === 1) {
|
||||
if (off + 16 > u8.length) return null;
|
||||
size = Number(dv.getBigUint64(off + 8));
|
||||
hdr = 16;
|
||||
} else if (size === 0) {
|
||||
size = u8.length - off;
|
||||
}
|
||||
if (size < hdr) return null;
|
||||
// next 允许超出已读范围:大文件的 meta 往往只读到头部,后面的数据按需再取
|
||||
return { typ: str(u8, off + 4, 4), payload: off + hdr, next: off + size };
|
||||
}
|
||||
|
||||
function findExifItemID(dv, u8, start, end) {
|
||||
if (start + 4 > u8.length) return 0;
|
||||
var ver = u8[start];
|
||||
var p = start + 4;
|
||||
var count = ver === 0 ? dv.getUint16(p) : dv.getUint32(p);
|
||||
p += ver === 0 ? 2 : 4;
|
||||
for (var i = 0; i < count; i++) {
|
||||
var b = boxHeader(dv, u8, p);
|
||||
if (!b) break;
|
||||
if (b.typ === 'infe' && b.payload < u8.length && u8[b.payload] >= 2 && b.payload + 12 <= u8.length) {
|
||||
if (str(u8, b.payload + 8, 4) === 'Exif') return dv.getUint16(b.payload + 4);
|
||||
}
|
||||
p = b.next;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function findItemExtent(dv, u8, start, wantID) {
|
||||
if (start + 8 > u8.length) return null;
|
||||
var ver = u8[start];
|
||||
var p = start + 4;
|
||||
var offsetSize = u8[p] >> 4, lengthSize = u8[p] & 0x0F;
|
||||
p++;
|
||||
var baseSize = u8[p] >> 4, indexSize = ver >= 1 ? (u8[p] & 0x0F) : 0;
|
||||
p++;
|
||||
if (!offsetSize || !lengthSize) return null;
|
||||
var count = dv.getUint16(p);
|
||||
p += 2;
|
||||
function readN(n) {
|
||||
if (n < 0 || p + n > u8.length) return null;
|
||||
var v = 0;
|
||||
for (var i = 0; i < n; i++) v = v * 256 + u8[p + i];
|
||||
p += n;
|
||||
return v;
|
||||
}
|
||||
for (var i = 0; i < count; i++) {
|
||||
var id = ver === 0 ? readN(2) : readN(4);
|
||||
if (id === null) return null;
|
||||
if (ver >= 1 && readN(2) === null) return null;
|
||||
if (readN(2) === null) return null; // data_reference_index
|
||||
var base = readN(baseSize);
|
||||
if (base === null) return null;
|
||||
var extents = readN(2);
|
||||
if (extents === null) return null;
|
||||
for (var e = 0; e < extents; e++) {
|
||||
if (ver >= 1 && indexSize > 0 && readN(indexSize) === null) return null;
|
||||
var off = readN(offsetSize), len = readN(lengthSize);
|
||||
if (off === null || len === null) return null;
|
||||
if (id === wantID) return { offset: base + off, length: len };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseHEIF(file, u8) {
|
||||
var dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
|
||||
var meta = null;
|
||||
for (var off = 0; off + 8 <= u8.length;) {
|
||||
var b = boxHeader(dv, u8, off);
|
||||
if (!b) break;
|
||||
if (b.typ === 'meta') { meta = b; break; }
|
||||
off = b.next;
|
||||
}
|
||||
if (!meta) throw new Error('HEIC 文件里没有找到 meta');
|
||||
var start = meta.payload + 4; // meta 是 FullBox
|
||||
|
||||
var iinf = null, iloc = null;
|
||||
for (var o = start; o + 8 <= u8.length && o < meta.next;) {
|
||||
var sb = boxHeader(dv, u8, o);
|
||||
if (!sb) break;
|
||||
if (sb.typ === 'iinf') iinf = sb;
|
||||
if (sb.typ === 'iloc') iloc = sb;
|
||||
o = sb.next;
|
||||
}
|
||||
if (!iinf || !iloc) throw new Error('HEIC 里缺少 iinf/iloc');
|
||||
|
||||
var itemID = findExifItemID(dv, u8, iinf.payload, iinf.next);
|
||||
if (!itemID) throw new Error('这张 HEIC 里没有 EXIF');
|
||||
var ext = findItemExtent(dv, u8, iloc.payload, itemID);
|
||||
if (!ext || ext.length <= 8) throw new Error('HEIC 的 EXIF 位置无效');
|
||||
|
||||
function finish(bytes) {
|
||||
var tiff = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0);
|
||||
if (tiff + 8 > bytes.length) tiff = 4;
|
||||
return parseTIFF(bytes, tiff);
|
||||
}
|
||||
// Exif item 已经读进来了就直接解析,否则按偏移再取那一小段
|
||||
if (ext.offset >= 0 && ext.offset + ext.length <= u8.length) {
|
||||
return finish(u8.subarray(ext.offset, ext.offset + ext.length));
|
||||
}
|
||||
return readRange(file, ext.offset, ext.offset + ext.length).then(finish);
|
||||
}
|
||||
|
||||
// ---------- 对外入口 ----------
|
||||
|
||||
function read(file) {
|
||||
return readRange(file, 0, HEAD_BYTES).then(function (head) {
|
||||
if (head.length >= 2 && head[0] === 0xFF && head[1] === 0xD8) {
|
||||
return Promise.resolve(parseJPEG(head));
|
||||
}
|
||||
if (head.length >= 12 && str(head, 4, 4) === 'ftyp') {
|
||||
return parseHEIF(file, head);
|
||||
}
|
||||
return Promise.reject(new Error('不认识的图片格式(支持 JPEG 与 HEIC/HEIF)'));
|
||||
});
|
||||
}
|
||||
|
||||
window.rondExif = { read: read };
|
||||
})();
|
||||
@@ -0,0 +1,119 @@
|
||||
/* 地点图片:在浏览器里转成 WebP 再上传。
|
||||
*
|
||||
* 为什么放在前端转:1 核的服务器解一张 4000px 的原图要好几秒,几张就够卡住整站;
|
||||
* 而用户自己的手机/电脑转一张是几十毫秒的事。服务端只负责把收到的 WebP 字节原样写出去。
|
||||
*
|
||||
* 尺寸与质量策略(在真机照片上量过才定的):
|
||||
* 长边 1440px,质量 0.72 → 超 220KB 再试 0.58 → 还超就把整张缩到 1080px 重编。
|
||||
* 关键在于「体积大头是像素数,不是质量」:实测一张 13MB 的城市照片,
|
||||
* 1600px 从 q0.74 一路降到 q0.5 只从 483KB 掉到 391KB(掉 19%,画面却明显糊了),
|
||||
* 而同样 q0.72 把长边从 1600 缩到 1024 直接掉到 217KB(掉 55%)。
|
||||
* 所以阶梯走到底还超标时,宁可缩尺寸也不再压质量。
|
||||
* 这几张图只用于详情页画廊与灯箱(最大显示到一千出头的宽度),1440px 留了余量。
|
||||
*/
|
||||
(function () {
|
||||
var MAX_EDGE = 1440;
|
||||
var QUALITY_LADDER = [0.72, 0.58];
|
||||
var TARGET_BYTES = 220 * 1024;
|
||||
// 兜底档:纹理特别密的照片在 1440px 下怎么降质都超标时才用
|
||||
var FALLBACK_EDGE = 1080;
|
||||
var FALLBACK_QUALITY = 0.7;
|
||||
|
||||
function loadImage(file) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var url = URL.createObjectURL(file);
|
||||
var img = new Image();
|
||||
img.onload = function () { URL.revokeObjectURL(url); resolve(img); };
|
||||
img.onerror = function () { URL.revokeObjectURL(url); reject(new Error('这张图片读不出来')); };
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function encode(cv, quality) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
cv.toBlob(function (blob) {
|
||||
// 不支持 WebP 编码的浏览器会静默给回 PNG,这里必须显式挡住——
|
||||
// 否则上传的是 PNG,服务端会以「只接受 WebP」拒掉,用户却看不出原因
|
||||
if (!blob || blob.type !== 'image/webp') {
|
||||
reject(new Error('这个浏览器不支持 WebP 转码,请换新版 Chrome / Edge / Safari'));
|
||||
return;
|
||||
}
|
||||
resolve(blob);
|
||||
}, 'image/webp', quality);
|
||||
});
|
||||
}
|
||||
|
||||
// render 把原图按长边上限放到一张画布上,返回画布与落地的宽高
|
||||
function render(img, limit) {
|
||||
var w = img.naturalWidth || img.width || 1;
|
||||
var h = img.naturalHeight || img.height || 1;
|
||||
var scale = Math.min(1, limit / Math.max(w, h));
|
||||
var tw = Math.max(1, Math.round(w * scale));
|
||||
var th = Math.max(1, Math.round(h * scale));
|
||||
var cv = document.createElement('canvas');
|
||||
cv.width = tw;
|
||||
cv.height = th;
|
||||
var g = cv.getContext('2d');
|
||||
if (g.imageSmoothingQuality !== undefined) g.imageSmoothingQuality = 'high';
|
||||
// 白底:源图带透明通道时(截图、PNG)不会在 WebP 里变成黑块
|
||||
g.fillStyle = '#fff';
|
||||
g.fillRect(0, 0, tw, th);
|
||||
g.drawImage(img, 0, 0, tw, th);
|
||||
return { cv: cv, width: tw, height: th };
|
||||
}
|
||||
|
||||
// toWebP(file) -> Promise<{blob, width, height, quality, bytes}>
|
||||
function toWebP(file, opts) {
|
||||
opts = opts || {};
|
||||
var maxEdge = opts.maxEdge || MAX_EDGE;
|
||||
var target = opts.targetBytes || TARGET_BYTES;
|
||||
var ladder = opts.ladder || QUALITY_LADDER;
|
||||
var fallbackEdge = opts.fallbackEdge === undefined ? FALLBACK_EDGE : opts.fallbackEdge;
|
||||
var fallbackQuality = opts.fallbackQuality || FALLBACK_QUALITY;
|
||||
return loadImage(file).then(function (img) {
|
||||
var box = render(img, maxEdge);
|
||||
function attempt(i) {
|
||||
var q = ladder[i];
|
||||
return encode(box.cv, q).then(function (blob) {
|
||||
var out = { blob: blob, width: box.width, height: box.height, quality: q, bytes: blob.size };
|
||||
if (out.bytes <= target || i === ladder.length - 1) return out;
|
||||
return attempt(i + 1);
|
||||
});
|
||||
}
|
||||
return attempt(0).then(function (out) {
|
||||
// 阶梯走到底还超标 ⇒ 这张图纹理特别密,再降质量只会又糊又大,改成缩尺寸
|
||||
var longest = Math.max(box.width, box.height);
|
||||
if (out.bytes <= target || !fallbackEdge || longest <= fallbackEdge) return out;
|
||||
var small = render(img, fallbackEdge);
|
||||
return encode(small.cv, fallbackQuality).then(function (blob) {
|
||||
// 兜底档同样是有损重编,纹理极端的图上未必比阶梯档小,所以比一次体积、取小的
|
||||
if (blob.size >= out.bytes) return out;
|
||||
return { blob: blob, width: small.width, height: small.height, quality: fallbackQuality, bytes: blob.size };
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function post(url, form) {
|
||||
return fetch(url, { method: 'POST', body: form, credentials: 'same-origin' })
|
||||
.then(function (r) { return r.json(); });
|
||||
}
|
||||
|
||||
// upload(srcPK, blob, w, h) -> Promise<{ok, id, url} | {error}>
|
||||
function upload(srcPK, blob, w, h) {
|
||||
var fd = new FormData();
|
||||
fd.append('src_pk', srcPK);
|
||||
fd.append('w', w);
|
||||
fd.append('h', h);
|
||||
fd.append('file', blob, 'image.webp');
|
||||
return post('/admin/place/images', fd);
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
var fd = new FormData();
|
||||
fd.append('id', id);
|
||||
return post('/admin/place/images/delete', fd);
|
||||
}
|
||||
|
||||
window.rondImage = { toWebP: toWebP, upload: upload, remove: remove };
|
||||
})();
|
||||
@@ -0,0 +1,930 @@
|
||||
/* rond 足迹地图:底图坐标系感知 + Leaflet 渲染
|
||||
*
|
||||
* 坐标系约定:数据库里的地点坐标是 GCJ-02(火星坐标,见 internal/geo/geo.go 的说明),
|
||||
* 因此画在 GCJ-02 底图(高德/腾讯)上时**不做任何转换**;
|
||||
* 只有换成 WGS-84 底图(天地图 / OSM / 世界迷雾)时才反算一次,否则会偏 500 米以上。
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var PI = 3.1415926535897932384626, A = 6378245.0, EE = 0.00669342162296594323;
|
||||
var DATA_CRS = 'gcj02';
|
||||
|
||||
// 交通方式图例:轨迹开启时告诉用户颜色含义
|
||||
function modeLegendControl(modes) {
|
||||
var Ctl = L.Control.extend({
|
||||
options: { position: 'bottomleft' },
|
||||
onAdd: function () {
|
||||
var box = L.DomUtil.create('div', 'mode-legend');
|
||||
box.style.cssText = 'background:rgba(255,255,255,.92);padding:6px 9px;border-radius:8px;' +
|
||||
'box-shadow:0 1px 4px rgba(0,0,0,.18);font-size:11.5px;line-height:1.7';
|
||||
modes.forEach(function (m) {
|
||||
var row = L.DomUtil.create('div');
|
||||
row.innerHTML = '<span style="display:inline-block;width:14px;height:3px;vertical-align:middle;' +
|
||||
'background:' + (m.color || '#30b0c7') + ';margin-right:6px"></span>' +
|
||||
m.name + ' <span style="color:#8a9199">' + m.count + '</span>';
|
||||
box.appendChild(row);
|
||||
});
|
||||
L.DomEvent.disableClickPropagation(box);
|
||||
return box;
|
||||
}
|
||||
});
|
||||
return new Ctl();
|
||||
}
|
||||
|
||||
// GPX 轨迹按需加载:勾选时才请求,切底图后按新坐标系重取
|
||||
var gpxCache = {};
|
||||
function ensureGpx(crs, done) {
|
||||
if (!gpxCache[crs]) {
|
||||
gpxCache[crs] = fetch('/api/gpx?crs=' + crs)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) { return d.tracks || []; })
|
||||
.catch(function () { delete gpxCache[crs]; return []; });
|
||||
}
|
||||
gpxCache[crs].then(done);
|
||||
}
|
||||
|
||||
// 按需加载 leaflet-heat:同一个 promise 复用,避免连点重复插入脚本
|
||||
var heatLoading = null;
|
||||
function ensureHeat(done) {
|
||||
if (L.heatLayer) { done(); return; }
|
||||
if (!heatLoading) {
|
||||
heatLoading = new Promise(function (resolve, reject) {
|
||||
var s = document.createElement('script');
|
||||
s.src = '/static/vendor/heat/leaflet-heat.js' + (window.__assetV ? '?v=' + window.__assetV : '');
|
||||
s.onload = resolve;
|
||||
s.onerror = reject;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
heatLoading.then(done, function () { heatLoading = null; });
|
||||
}
|
||||
|
||||
// 迷雾浓度滑杆:不同底图明暗差异大,让用户自己调节更直观。
|
||||
// 返回控件本身,便于迷雾被关掉时把滑杆一并收起来——图层关着、滑杆还在,
|
||||
// 会让人以为迷雾已经打开了。
|
||||
function addFogOpacityControl(map, layer, corner) {
|
||||
var Ctl = L.Control.extend({
|
||||
options: { position: corner },
|
||||
onAdd: function () {
|
||||
var box = L.DomUtil.create('div', 'leaflet-bar fog-opacity');
|
||||
// 标签做成按钮:窄屏用它把滑杆收起来(右下角控件太多时太挤)
|
||||
box.innerHTML = '<button type="button" class="fog-label">迷雾浓度</button>';
|
||||
var label = box.querySelector('.fog-label');
|
||||
var slider = L.DomUtil.create('input', 'fog-slider');
|
||||
slider.type = 'range';
|
||||
slider.min = '20';
|
||||
slider.max = '100';
|
||||
slider.step = '5';
|
||||
slider.value = String(Math.round(layer.options.opacity * 100));
|
||||
box.appendChild(slider);
|
||||
L.DomEvent.disableClickPropagation(box);
|
||||
L.DomEvent.disableScrollPropagation(box);
|
||||
L.DomEvent.on(label, 'click', function () { box.classList.toggle('open'); });
|
||||
L.DomEvent.on(slider, 'input', function () {
|
||||
var v = parseInt(slider.value, 10) / 100;
|
||||
layer.setOpacity(v);
|
||||
localStorage.setItem('rond.fogOpacity', String(v));
|
||||
});
|
||||
return box;
|
||||
}
|
||||
});
|
||||
return new Ctl().addTo(map);
|
||||
}
|
||||
|
||||
function inChina(lat, lon) {
|
||||
return lon >= 73.66 && lon <= 135.05 && lat >= 3.86 && lat <= 53.55;
|
||||
}
|
||||
|
||||
function transformLat(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 * PI) + 20 * Math.sin(2 * x * PI)) * 2 / 3;
|
||||
r += (20 * Math.sin(y * PI) + 40 * Math.sin(y / 3 * PI)) * 2 / 3;
|
||||
r += (160 * Math.sin(y / 12 * PI) + 320 * Math.sin(y * PI / 30)) * 2 / 3;
|
||||
return r;
|
||||
}
|
||||
|
||||
function transformLon(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 * PI) + 20 * Math.sin(2 * x * PI)) * 2 / 3;
|
||||
r += (20 * Math.sin(x * PI) + 40 * Math.sin(x / 3 * PI)) * 2 / 3;
|
||||
r += (150 * Math.sin(x / 12 * PI) + 300 * Math.sin(x / 30 * PI)) * 2 / 3;
|
||||
return r;
|
||||
}
|
||||
|
||||
// WGS-84 -> GCJ-02
|
||||
function wgsToGcj(lat, lon) {
|
||||
if (!inChina(lat, lon)) return [lat, lon];
|
||||
var dLat = transformLat(lon - 105, lat - 35);
|
||||
var dLon = transformLon(lon - 105, lat - 35);
|
||||
var radLat = lat / 180 * PI;
|
||||
var magic = Math.sin(radLat);
|
||||
magic = 1 - EE * magic * magic;
|
||||
var sqrtMagic = Math.sqrt(magic);
|
||||
dLat = (dLat * 180) / ((A * (1 - EE)) / (magic * sqrtMagic) * PI);
|
||||
dLon = (dLon * 180) / (A / sqrtMagic * Math.cos(radLat) * PI);
|
||||
return [lat + dLat, lon + dLon];
|
||||
}
|
||||
|
||||
// GCJ-02 -> WGS-84,正变换没有解析反函数,不动点迭代即可
|
||||
function gcjToWgs(lat, lon) {
|
||||
if (!inChina(lat, lon)) return [lat, lon];
|
||||
var wLat = lat, wLon = lon;
|
||||
for (var i = 0; i < 5; i++) {
|
||||
var g = wgsToGcj(wLat, wLon);
|
||||
wLat += lat - g[0];
|
||||
wLon += lon - g[1];
|
||||
}
|
||||
return [wLat, wLon];
|
||||
}
|
||||
|
||||
function project(lat, lon, crs) {
|
||||
if (crs === 'wgs84') return gcjToWgs(lat, lon);
|
||||
return [lat, lon];
|
||||
}
|
||||
|
||||
var APPLE = {
|
||||
red: '#ff3b30', orange: '#ff9500', yellow: '#e8b700', green: '#34c759', mint: '#00c7be',
|
||||
teal: '#30b0c7', cyan: '#32ade6', blue: '#007aff', indigo: '#5856d6', purple: '#af52de',
|
||||
pink: '#ff2d55', brown: '#a2845e', gray: '#8e8e93'
|
||||
};
|
||||
|
||||
var maps = {};
|
||||
var cfgCache = null;
|
||||
|
||||
function register(id, map) {
|
||||
maps[id] = map;
|
||||
window.__rondMaps = maps;
|
||||
return map;
|
||||
}
|
||||
|
||||
function colorOf(name) {
|
||||
if (!name) return '#5b6169';
|
||||
if (APPLE[name]) return APPLE[name];
|
||||
return /^#/.test(name) ? name : '#5b6169';
|
||||
}
|
||||
|
||||
// 底图配置由服务端下发:需要密钥的图层走 /tiles/... 代理,密钥不进页面
|
||||
function loadConfig() {
|
||||
if (cfgCache) return Promise.resolve(cfgCache);
|
||||
return fetch('/api/map-config', { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (c) {
|
||||
if (!c || !c.base || !c.base.length) throw new Error('底图配置为空');
|
||||
cfgCache = c;
|
||||
return c;
|
||||
})
|
||||
.catch(function () {
|
||||
// 兜底:直接用免密钥高德瓦片,保证地图不会空白
|
||||
cfgCache = {
|
||||
base: [{
|
||||
key: 'amap-street', label: '街道', crs: 'gcj02', maxZoom: 18,
|
||||
url: 'https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}',
|
||||
attr: '© 高德地图'
|
||||
}],
|
||||
overlays: []
|
||||
};
|
||||
return cfgCache;
|
||||
});
|
||||
}
|
||||
|
||||
function tileLayer(def) {
|
||||
return L.tileLayer(def.url, {
|
||||
subdomains: ['1', '2', '3', '4'],
|
||||
maxZoom: def.maxZoom || 18,
|
||||
minZoom: 3,
|
||||
attribution: def.attr || ''
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 默认底图 ----------
|
||||
* 三处来源,优先级从高到低:
|
||||
* 1) 访客自己在图上切过的那张(localStorage,换设备就没了,但不用每次重切)
|
||||
* 2) 后台「设置 → 地图底图 → 默认用哪张底图」设的那张
|
||||
* 3) 配置里的第一张
|
||||
* Key 对不上(换过 map_provider、旧配置残留)就往下落,绝不让地图空着。
|
||||
*/
|
||||
var BASE_PICK = 'rond.base';
|
||||
|
||||
function pickBase(cfg) {
|
||||
var byKey = {};
|
||||
cfg.base.forEach(function (b) { byKey[b.key] = b; });
|
||||
try {
|
||||
var saved = window.localStorage.getItem(BASE_PICK);
|
||||
if (saved && byKey[saved]) return byKey[saved];
|
||||
} catch (e) { /* 隐私模式禁用了 localStorage,忽略即可 */ }
|
||||
if (cfg.default && byKey[cfg.default]) return byKey[cfg.default];
|
||||
return cfg.base[0];
|
||||
}
|
||||
|
||||
function rememberBase(key) {
|
||||
if (!key) return;
|
||||
try { window.localStorage.setItem(BASE_PICK, key); } catch (e) { /* 同上 */ }
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
|
||||
function dur(min) {
|
||||
min = Math.round(min || 0);
|
||||
if (min <= 0) return '—';
|
||||
var d = Math.floor(min / 1440), h = Math.floor((min % 1440) / 60), m = min % 60;
|
||||
if (d > 0) return d + '天' + (h ? h + '小时' : '');
|
||||
if (h > 0) return h + '小时' + (m ? m + '分' : '');
|
||||
return m + '分';
|
||||
}
|
||||
|
||||
/* ---------- 推荐 / 踩雷:地图气泡里直接表决 ----------
|
||||
* 票数一次性从 /api/votes 取回(个人站点规模很小),气泡渲染时同步查表,
|
||||
* 不为每个点单独发请求;投票就地 AJAX,不刷新页面。
|
||||
*/
|
||||
var voteData = null; // {verdicts:{}, tallies:{}, mine:{}}
|
||||
var voteMeta = { admin: false, public: false };
|
||||
var voteLoad = null;
|
||||
|
||||
function loadVotes() {
|
||||
if (voteLoad) return voteLoad;
|
||||
voteLoad = fetch('/api/votes', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
voteMeta.admin = !!d.admin;
|
||||
voteMeta.public = !!d.public;
|
||||
voteData = { verdicts: d.verdicts || {}, tallies: d.tallies || {}, mine: d.mine || {} };
|
||||
repaintVotes();
|
||||
return voteData;
|
||||
})
|
||||
.catch(function () { voteLoad = null; return null; });
|
||||
return voteLoad;
|
||||
}
|
||||
|
||||
function verdictOf(pk, fallback) {
|
||||
if (voteData && voteData.verdicts[pk] != null) return voteData.verdicts[pk];
|
||||
return fallback || 0;
|
||||
}
|
||||
|
||||
function tallyOf(pk, up, down) {
|
||||
var t = voteData && voteData.tallies[pk];
|
||||
return t || { up: up || 0, down: down || 0 };
|
||||
}
|
||||
|
||||
function paintVote(box, p) {
|
||||
var pk = p.sp;
|
||||
// 票数与身份(站长 / 访客)还没拿到时先不画,免得先闪出「大家怎么说」再变成「我的结论」
|
||||
if (!voteData) { box.style.display = 'none'; return; }
|
||||
if (!(voteMeta.admin || voteMeta.public)) { box.style.display = 'none'; return; }
|
||||
box.style.display = '';
|
||||
// 管理员看自己的结论(点已选中的按钮可取消);访客看大家怎么说
|
||||
if (voteMeta.admin) {
|
||||
var v = verdictOf(pk, p.vd);
|
||||
box.innerHTML = '<span class="mp-vl">我的结论</span>' +
|
||||
'<button type="button" class="mp-vb up' + (v === 1 ? ' on' : '') + '" data-set="' + (v === 1 ? 0 : 1) + '">👍 推荐</button>' +
|
||||
'<button type="button" class="mp-vb down' + (v === -1 ? ' on' : '') + '" data-set="' + (v === -1 ? 0 : -1) + '">👎 踩雷</button>';
|
||||
} else {
|
||||
var t = tallyOf(pk, p.u, p.dn), m = mineArea(pk);
|
||||
box.innerHTML = '<span class="mp-vl">大家怎么说</span>' +
|
||||
'<button type="button" class="mp-vb up' + (m === 1 ? ' on' : '') + '" data-set="1">👍 <b>' + t.up + '</b></button>' +
|
||||
'<button type="button" class="mp-vb down' + (m === -1 ? ' on' : '') + '" data-set="-1">👎 <b>' + t.down + '</b></button>';
|
||||
}
|
||||
Array.prototype.forEach.call(box.querySelectorAll('.mp-vb'), function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
if (b.disabled) return;
|
||||
b.disabled = true;
|
||||
var body = voteMeta.admin
|
||||
? 'src_pk=' + pk + '&verdict=' + b.getAttribute('data-set')
|
||||
: 'src_pk=' + pk + '&vote=' + b.getAttribute('data-set');
|
||||
fetch(voteMeta.admin ? '/api/verdict' : '/api/vote', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
credentials: 'same-origin',
|
||||
body: body
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (d.error) { b.disabled = false; return; }
|
||||
if (!voteData) voteData = { verdicts: {}, tallies: {}, mine: {} };
|
||||
if (voteMeta.admin) {
|
||||
if (d.verdict) voteData.verdicts[pk] = d.verdict; else delete voteData.verdicts[pk];
|
||||
} else {
|
||||
voteData.tallies[pk] = { up: d.up, down: d.down };
|
||||
if (d.mine) voteData.mine[pk] = d.mine; else delete voteData.mine[pk];
|
||||
}
|
||||
paintVote(box, p);
|
||||
})
|
||||
.catch(function () { b.disabled = false; });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mineArea(pk) {
|
||||
return voteData && voteData.mine[pk] ? voteData.mine[pk] : 0;
|
||||
}
|
||||
|
||||
// 票数异步到货后,把已经打开的气泡补上真实票数
|
||||
function repaintVotes() {
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.mp-vote'), function (box) {
|
||||
if (box.__p) paintVote(box, box.__p);
|
||||
});
|
||||
}
|
||||
|
||||
function popup(p, vote) {
|
||||
var wrap = document.createElement('div');
|
||||
wrap.className = 'mp';
|
||||
var parts = ['<div class="mp-n">' + esc(p.n || '未命名地点') + '</div>'];
|
||||
var meta = [];
|
||||
if (p.a) meta.push(esc(p.a));
|
||||
if (p.c) meta.push(esc(p.c));
|
||||
if (meta.length) parts.push('<div class="mp-m">' + meta.join(' · ') + '</div>');
|
||||
parts.push('<div class="mp-m">到访 <b>' + p.v + '</b> 次 · 停留 <b>' + dur(p.d) + '</b></div>');
|
||||
// 备注只读直出,省得多点一次详情页
|
||||
if (p.note) parts.push('<div class="mp-note">' + esc(p.note) + '</div>');
|
||||
if (p.t) parts.push('<div class="mp-m">最近一次 ' + esc(p.t) + '</div>');
|
||||
parts.push('<div class="mp-foot"><a href="/places/' + p.id + '">查看详情 →</a></div>');
|
||||
wrap.innerHTML = parts.join('');
|
||||
if (vote && p.sp) {
|
||||
// 无论如何都挂上,交给 paintVote 决定显不显示;
|
||||
// 不挂的话票数到货后的 repaintVotes() 就找不到它了
|
||||
var box = L.DomUtil.create('div', 'mp-vote');
|
||||
box.__p = p;
|
||||
paintVote(box, p);
|
||||
wrap.appendChild(box);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// radiusOf 让点的大小反映到访次数(按当前这批里最多的那个归一化)
|
||||
function radiusOf(p, max) {
|
||||
return 4 + Math.min(9, Math.sqrt(p.v / max) * 9);
|
||||
}
|
||||
|
||||
function maxVisit(points) {
|
||||
var max = 1;
|
||||
points.forEach(function (p) { if (p.v > max) max = p.v; });
|
||||
return max;
|
||||
}
|
||||
|
||||
// makeMarker 造一个点。抽出来是为了让 setPoints 能只补差集:
|
||||
// 整体重建会让所有点同时闪一下,聚合的展开状态也会丢。
|
||||
function makeMarker(p, crs, max, vote) {
|
||||
var c = project(p.lat, p.lon, crs);
|
||||
var m = L.circleMarker([c[0], c[1]], baseStyle(p, max));
|
||||
// 传函数而不是字符串:每次打开都重算票数与「我投的」,避免陈旧的闭包数据
|
||||
// maxWidth 要比默认的 300 大,否则「我的结论 👍 推荐 👎 踩雷」会被挤成两行
|
||||
m.bindPopup(function () { return popup(p, vote); },
|
||||
{ closeButton: true, maxWidth: 360, minWidth: 240 });
|
||||
m.bindTooltip(p.n || '未命名', { direction: 'top', offset: [0, -4] });
|
||||
m.__p = p; // 记住这份数据是谁的,用来判断下批是不是「重新拉的」
|
||||
return m;
|
||||
}
|
||||
|
||||
function makeGroup(useCluster) {
|
||||
return useCluster && L.markerClusterGroup
|
||||
? L.markerClusterGroup({ maxClusterRadius: 46, disableClusteringAtZoom: 15, showCoverageOnHover: false })
|
||||
: L.layerGroup();
|
||||
}
|
||||
|
||||
// index 可选:传了就顺手填好「点 id → 图层」,供 setPoints 做增量更新,
|
||||
// 也供 highlight() 按 key 找到某个点。没有 id 的点用调用方给的 p.key 当键
|
||||
// (照片导入页传的是行号,它本来就没有库内 id)。
|
||||
function markers(points, crs, useCluster, vote, index) {
|
||||
var group = makeGroup(useCluster);
|
||||
var max = maxVisit(points);
|
||||
points.forEach(function (p) {
|
||||
var m = makeMarker(p, crs, max, vote);
|
||||
if (index) index[p.id !== undefined ? p.id : p.key] = m;
|
||||
group.addLayer(m);
|
||||
});
|
||||
return group;
|
||||
}
|
||||
|
||||
// 点的「常态样式」:highlight 复原时要用同一套值,所以抽出来
|
||||
function baseStyle(p, max) {
|
||||
return {
|
||||
radius: radiusOf(p, max), color: '#fff', weight: 1.5,
|
||||
fillColor: colorOf(p.k), fillOpacity: 0.9
|
||||
};
|
||||
}
|
||||
|
||||
function reducedMotion() {
|
||||
return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
||||
}
|
||||
|
||||
// popIn 给一个元素打上入场动画。动画结束后把类和延时清掉,
|
||||
// 免得这个元素之后被复用(缩放时 Leaflet 会换元素)时莫名其妙又跑一遍。
|
||||
function popIn(el, delay) {
|
||||
if (!el || !el.classList) return;
|
||||
el.style.animationDelay = delay;
|
||||
el.classList.add('pt-new');
|
||||
el.addEventListener('animationend', function () {
|
||||
el.classList.remove('pt-new');
|
||||
el.style.animationDelay = '';
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
// 新出现的点做一次「冒出」动画,一批按顺序错开一点,看起来是"扫过来"的。
|
||||
//
|
||||
// 必须等图层真正上屏再打类:markerClusterGroup 是**异步**把子标记渲染成 DOM 的,
|
||||
// addLayer 之后立刻 getElement() 拿到的是 null(这也是第一版动画完全没生效的原因)。
|
||||
// 所以挂 add 事件,并且顺手判一次「是不是已经上屏了」。
|
||||
function animateIn(markers) {
|
||||
if (reducedMotion()) return;
|
||||
markers.forEach(function (m, i) {
|
||||
var delay = Math.min(i, 24) * 10 + 'ms';
|
||||
var done = false;
|
||||
var fire = function () {
|
||||
if (done) return;
|
||||
done = true;
|
||||
popIn(m.getElement && m.getElement(), delay);
|
||||
};
|
||||
if (m.once) m.once('add', fire);
|
||||
fire();
|
||||
});
|
||||
}
|
||||
|
||||
// 聚合图标不能用 DOM 元素本身当身份:markerCluster 每次数据变动都会重建它们
|
||||
// (实测一轮更新里往 marker pane 加了 791 个元素),拿元素当 key 每次都被当成
|
||||
// 「第一次见到」,结果就是什么都不会触发。用它的**位置**当身份才稳。
|
||||
function clusterKey(el) {
|
||||
var t = el.style.transform || '';
|
||||
var m = t.match(/-?\d+(?:\.\d+)?/g);
|
||||
if (m && m.length >= 2) {
|
||||
// 位置量化到 40px 一格:点集一变,聚合中心会跟着微微挪动,
|
||||
// 不量化的话同一个圈每次都被当成「新出现的」,回放时一片乱闪。
|
||||
return Math.round(Number(m[0]) / 40) + ',' + Math.round(Number(m[1]) / 40);
|
||||
}
|
||||
return el.style.left + ',' + el.style.top;
|
||||
}
|
||||
|
||||
// clusterState 把当前所有聚合圈的位置 → 数字记下来,并在数字变化 /
|
||||
// 出现新位置时给相应元素打动画类。返回给下一次调用当基线。
|
||||
function clusterState(map, prev) {
|
||||
var now = {};
|
||||
var changed = [];
|
||||
var appeared = [];
|
||||
[].slice.call(map.getContainer().querySelectorAll('.marker-cluster')).forEach(function (el) {
|
||||
var inner = el.firstElementChild;
|
||||
if (!inner) return;
|
||||
var key = clusterKey(el);
|
||||
var n = (inner.textContent || '').trim();
|
||||
now[key] = n;
|
||||
if (!prev) return; // 第一次只记基线:别一上来满屏都在动
|
||||
if (prev[key] === undefined) appeared.push(el);
|
||||
else if (prev[key] !== n) changed.push(inner);
|
||||
});
|
||||
if (!prev || reducedMotion()) return now;
|
||||
appeared.forEach(function (el, i) { popIn(el, Math.min(i, 24) * 10 + 'ms'); });
|
||||
// 数字变了轻弹一下。回放时在聚合视图下主要看到的就是数字在跳,
|
||||
// 光靠新圆圈淡入几乎看不出来。弹内层 div —— 外层带着 Leaflet 的定位 transform。
|
||||
changed.forEach(function (inner, i) {
|
||||
setTimeout(function () {
|
||||
inner.classList.remove('pt-pulse');
|
||||
void inner.offsetWidth; // 强制回流:连着两次同样的动画不加这句不会重放
|
||||
inner.classList.add('pt-pulse');
|
||||
inner.addEventListener('animationend', function () {
|
||||
inner.classList.remove('pt-pulse');
|
||||
}, { once: true });
|
||||
}, Math.min(i, 24) * 10);
|
||||
});
|
||||
return now;
|
||||
}
|
||||
|
||||
function fit(map, points, crs, pad) {
|
||||
var lls = points.map(function (p) {
|
||||
var c = project(p.lat, p.lon, crs);
|
||||
return [c[0], c[1]];
|
||||
}).filter(function (c) { return c[0] || c[1]; });
|
||||
if (!lls.length) {
|
||||
map.setView([26.6, 106.7], 11);
|
||||
return;
|
||||
}
|
||||
map.fitBounds(L.latLngBounds(lls).pad(pad == null ? 0.15 : pad));
|
||||
}
|
||||
|
||||
// 底图坐标系可能随图层切换而改变(例如高德 -> 天地图),此时必须重画标记
|
||||
function state4(map, points, group, crs, vote) {
|
||||
return {
|
||||
map: map, points: points, group: group, crs: crs, vote: vote,
|
||||
apply: function (nextCrs) {
|
||||
if (nextCrs === this.crs) return;
|
||||
this.map.removeLayer(this.group);
|
||||
this.crs = nextCrs;
|
||||
this.group = markers(this.points, this.crs, this.clustered, this.vote);
|
||||
this.group.addTo(this.map);
|
||||
fit(this.map, this.points, this.crs, this.pad);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
window.rondMap = {
|
||||
gcj: wgsToGcj,
|
||||
gcjToWgs: gcjToWgs,
|
||||
colorOf: colorOf,
|
||||
esc: esc,
|
||||
dur: dur,
|
||||
|
||||
// 简易预览:主页 / 地点详情用,无聚类、无控件。
|
||||
// 默认不带就地表决(详情页下方本来就有表决卡片,重复一套按钮反而混乱);
|
||||
// 需要时传 vote: true。
|
||||
preview: function (opts) {
|
||||
var points = opts.points || [];
|
||||
var vote = opts.vote === true;
|
||||
// 同一个容器画第二次(照片导入页反查完画一次、点「重新反查」又画一次):
|
||||
// Leaflet 不允许对已经有地图的容器再 L.map(),会抛
|
||||
// "Map container is already initialized."——那个错会被调用方的 catch 接住,
|
||||
// 于是「重新反查」明明成功了却报一句失败。这里直接复用那份实例换一批点。
|
||||
// 判据用「容器有没有地图」(register 记在 maps[] 里),不是「状态建全没有」:
|
||||
// 只要容器已被 Leaflet 认领过,再 L.map() 就一定会抛,哪怕上次的状态没建完。
|
||||
// 容器可能刚从 display:none 变回可见,先量一次尺寸(换点会顺手重贴视野)。
|
||||
var live = maps[opts.id];
|
||||
if (live) {
|
||||
live.invalidateSize();
|
||||
var prev = window.rondMap._state[opts.id];
|
||||
if (prev && prev.setPoints) prev.setPoints(points);
|
||||
else if (prev) prev.previewPending = points; // 配置还没回来,等 setPoints 建好再补画
|
||||
return;
|
||||
}
|
||||
var map = L.map(opts.id, { zoomControl: false, attributionControl: false, scrollWheelZoom: false });
|
||||
var st = null;
|
||||
register(opts.id, map);
|
||||
if (vote) loadVotes();
|
||||
loadConfig().then(function (cfg) {
|
||||
var def = pickBase(cfg);
|
||||
tileLayer(def).addTo(map);
|
||||
var byId = {};
|
||||
var group = markers(points, def.crs, false, vote, byId);
|
||||
group.addTo(map);
|
||||
var applyFit = function () { fit(map, points, def.crs, 0.06); };
|
||||
applyFit();
|
||||
// 点击缩略图进全屏地图:只有主页那种「预览」需要(opts.clickToFull)。
|
||||
// 地点详情页不能要——它上面本来就有「在地图中查看」按钮,点地图就走很突兀;
|
||||
// 后台照片页更不能要——点一下地图就被甩出后台。
|
||||
if (opts.clickToFull) {
|
||||
map.on('click', function () { window.location.href = '/map'; });
|
||||
}
|
||||
// 容器尺寸变化后重新贴合视野(卡片布局在字体加载后可能变高)
|
||||
window.addEventListener('resize', function () { map.invalidateSize(); applyFit(); });
|
||||
st = state4(map, points, group, def.crs, vote);
|
||||
st.clustered = false;
|
||||
st.pad = 0.06;
|
||||
st.byId = byId;
|
||||
st.max = maxVisit(points);
|
||||
st.setPoints = function (pts) {
|
||||
points = st.points = pts;
|
||||
if (st.hl) st.hl = null; // 旧图层马上要被丢掉,别留着悬空引用
|
||||
map.removeLayer(st.group);
|
||||
st.byId = {};
|
||||
st.max = maxVisit(pts);
|
||||
st.group = markers(pts, st.crs, false, vote, st.byId);
|
||||
st.group.addTo(map);
|
||||
applyFit();
|
||||
};
|
||||
window.rondMap._state[opts.id] = st;
|
||||
// 配置就绪前有别的调用挂上来的点,现在补画一次
|
||||
if (st.previewPending) {
|
||||
var queued = st.previewPending;
|
||||
st.previewPending = null;
|
||||
st.setPoints(queued);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// highlight(id, key):把某个点临时挑出来(放大、换醒目色、浮出名字、压到最上层)。
|
||||
// key 传 null / undefined 复原。key 取点的 id(full() 那套),没有 id 时取 p.key
|
||||
// ——照片导入页传的是行号,拿它做「鼠标划到哪一行,图上哪个点跳出来」。
|
||||
// 视野里没有这个点时才把它平移进来:划过一行就动一次地图会很晃。
|
||||
highlight: function (id, key) {
|
||||
var st = window.rondMap._state[id];
|
||||
if (!st || !st.byId || !st.map) return;
|
||||
if (st.hl) {
|
||||
st.hl.setStyle(baseStyle(st.hl.__p || {}, st.max || 1));
|
||||
st.hl.closeTooltip();
|
||||
st.hl = null;
|
||||
}
|
||||
if (key === null || key === undefined) return;
|
||||
var m = st.byId[key];
|
||||
if (!m) return;
|
||||
m.setStyle({
|
||||
radius: radiusOf(m.__p || {}, st.max || 1) + 8,
|
||||
color: '#fff', weight: 3, fillColor: '#e8590c', fillOpacity: 1
|
||||
});
|
||||
m.bringToFront();
|
||||
m.openTooltip();
|
||||
var ll = m.getLatLng();
|
||||
if (!st.map.getBounds().pad(-0.12).contains(ll)) st.map.panTo(ll);
|
||||
st.hl = m;
|
||||
},
|
||||
|
||||
// 全屏地图:聚类 + 图层切换 + 坐标纠偏
|
||||
full: function (opts) {
|
||||
var map = L.map(opts.id, { zoomControl: false, worldCopyJump: false, attributionControl: false });
|
||||
// 默认那个角标会显示「Leaflet | © 高德地图」,其中「Leaflet」是渲染库自己的链接,
|
||||
// 对访客没有任何意义,去掉(prefix: false 只去掉这一半)。
|
||||
// 底图版权(© 高德地图 / © OpenStreetMap)必须留着——那是上游的使用条款要求。
|
||||
L.control.attribution({ prefix: false, position: 'bottomright' }).addTo(map);
|
||||
var points = opts.points || [];
|
||||
// 窄屏筛选面板占满宽度,控件改挂右下角(该尺寸下图例已隐藏)
|
||||
var corner = window.matchMedia('(max-width: 620px)').matches ? 'bottomright' : 'topright';
|
||||
L.control.zoom({ position: corner }).addTo(map);
|
||||
L.control.scale({ imperial: false, position: 'bottomleft' }).addTo(map);
|
||||
|
||||
register(opts.id, map);
|
||||
window.rondMap._state[opts.id] = { pending: true, points: points };
|
||||
loadVotes();
|
||||
|
||||
loadConfig().then(function (cfg) {
|
||||
var bases = {}, def = pickBase(cfg);
|
||||
cfg.base.forEach(function (b) {
|
||||
var l = tileLayer(b);
|
||||
l.__crs = b.crs;
|
||||
l.__key = b.key;
|
||||
bases[b.label] = l;
|
||||
});
|
||||
var active = bases[def.label];
|
||||
active.addTo(map);
|
||||
|
||||
// 瓦片加载兜底:当前底图短时间内连续加载失败就自动切到别的底图。
|
||||
// Leaflet 只是本地渲染库,失败的是上游瓦片源(高德等),这里把选择权自动接过来。
|
||||
var errCount = 0, errTimer = null;
|
||||
function noteFallback(msg) {
|
||||
var el = document.createElement('div');
|
||||
el.textContent = msg;
|
||||
el.style.cssText = 'position:absolute;left:50%;transform:translateX(-50%);top:10px;z-index:1000;' +
|
||||
'background:rgba(30,40,50,.85);color:#fff;padding:7px 14px;border-radius:8px;font-size:12.5px';
|
||||
map.getContainer().appendChild(el);
|
||||
setTimeout(function () { el.remove(); }, 6000);
|
||||
}
|
||||
Object.keys(bases).forEach(function (label) {
|
||||
bases[label].on('tileerror', function () {
|
||||
if (map.hasLayer(bases[label]) && bases[label] === active) {
|
||||
errCount++;
|
||||
clearTimeout(errTimer);
|
||||
errTimer = setTimeout(function () { errCount = 0; }, 10000);
|
||||
if (errCount >= 8) {
|
||||
errCount = 0;
|
||||
var others = Object.keys(bases).filter(function (l) { return l !== label; });
|
||||
if (!others.length) return;
|
||||
map.removeLayer(active);
|
||||
active = bases[others[0]];
|
||||
active.addTo(map);
|
||||
st.apply(active.__crs);
|
||||
noteFallback('「' + label + '」瓦片加载失败,已自动切换到「' + others[0] + '」');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 天地图的注记是独立图层,必须叠在底图之上
|
||||
(cfg.overlays || []).forEach(function (o) {
|
||||
var ol = tileLayer(o);
|
||||
ol.__crs = o.crs;
|
||||
ol.addTo(map);
|
||||
});
|
||||
// 世界迷雾图层:瓦片由服务端按当前底图坐标系纠偏后渲染,
|
||||
// 切换底图坐标系时必须换 URL 重新拉取。
|
||||
// 专题可以在管理页关掉迷雾(opts.showFog=false),此时图层、浓度滑杆、图层开关都不出现。
|
||||
var wantFog = opts.showFog !== false;
|
||||
var fogLayer = null;
|
||||
if (wantFog && cfg.fog && cfg.fog.blocks > 0) {
|
||||
fogLayer = L.tileLayer('/tiles/fog/{z}/{x}/{y}?crs=' + def.crs + '&v=' + cfg.fog.ver, {
|
||||
minZoom: 3, maxZoom: 18, opacity: 0.9
|
||||
});
|
||||
}
|
||||
var overlayDefs = {};
|
||||
if (fogLayer) {
|
||||
overlayDefs['世界迷雾'] = fogLayer;
|
||||
var savedFog = parseFloat(localStorage.getItem('rond.fogOpacity'));
|
||||
if (!isNaN(savedFog) && savedFog > 0 && savedFog <= 1) fogLayer.setOpacity(savedFog);
|
||||
var fogCtl = addFogOpacityControl(map, fogLayer, corner);
|
||||
// 默认打开。浓度滑杆一直摆在图上,迷雾要是默认关着,用户会以为这功能已经开了;
|
||||
// 只有用户自己关过(rond.fogOff=1)才不显示,关掉时把滑杆一并收起来。
|
||||
var syncFog = function () {
|
||||
var on = map.hasLayer(fogLayer);
|
||||
var box = fogCtl.getContainer();
|
||||
if (box) box.style.display = on ? '' : 'none';
|
||||
localStorage.setItem('rond.fogOff', on ? '0' : '1');
|
||||
};
|
||||
if (localStorage.getItem('rond.fogOff') === '1') syncFog();
|
||||
else fogLayer.addTo(map);
|
||||
map.on('overlayadd overlayremove', function (e) {
|
||||
if (e.name === '世界迷雾') syncFog();
|
||||
});
|
||||
}
|
||||
if (Object.keys(bases).length > 1 || fogLayer) {
|
||||
L.control.layers(bases, overlayDefs, { position: corner }).addTo(map);
|
||||
// 记住访客挑的底图:下次进来直接铺他喜欢的那张(含「纯路网」这种少标注的)
|
||||
map.on('baselayerchange', function (e) {
|
||||
if (e.layer) rememberBase(e.layer.__key);
|
||||
});
|
||||
}
|
||||
|
||||
var group = markers(points, def.crs, true, true);
|
||||
group.addTo(map);
|
||||
|
||||
// 专题页要的是「区域视野」而不是「点集视野」:只框住点的话,
|
||||
// 只去过一两个地方时视野会缩得过小,区域内的迷雾也看不全。
|
||||
// 传 fitCircle(中心 + 半径)或 fitBBox 就能覆盖默认的点集贴合。
|
||||
var applyFit = function () {
|
||||
if (opts.fitCircle && opts.fitCircle.radiusKm > 0) {
|
||||
var c = project(opts.fitCircle.lat, opts.fitCircle.lon, def.crs);
|
||||
map.fitBounds(L.latLng(c[0], c[1]).toBounds(opts.fitCircle.radiusKm * 2000));
|
||||
return;
|
||||
}
|
||||
if (opts.fitBBox) {
|
||||
var b = opts.fitBBox;
|
||||
var p1 = project(b.minLat, b.minLon, def.crs), p2 = project(b.maxLat, b.maxLon, def.crs);
|
||||
map.fitBounds(L.latLngBounds([[p1[0], p1[1]], [p2[0], p2[1]]]).pad(0.12));
|
||||
return;
|
||||
}
|
||||
fit(map, points, def.crs);
|
||||
};
|
||||
applyFit();
|
||||
|
||||
var st = state4(map, points, group, def.crs, true);
|
||||
st.clustered = true;
|
||||
st.pad = 0.15;
|
||||
st.apply = function (next) {
|
||||
if (next === this.crs) return;
|
||||
this.crs = next;
|
||||
this.map.removeLayer(this.group);
|
||||
this.group = markers(this.points, this.crs, true, this.vote);
|
||||
this.group.addTo(this.map);
|
||||
applyFit();
|
||||
};
|
||||
window.rondMap._state[opts.id] = st;
|
||||
|
||||
// 行程轨迹与到访热力:都基于 GCJ-02 数据,随底图坐标系重投影
|
||||
var trackLayer = null, trackSegs = null, heatLayer = null;
|
||||
var cityLayer = null, gpxLayer = null, modeLegend = null;
|
||||
function buildTrack(segs, crs) {
|
||||
var g = L.layerGroup();
|
||||
segs.forEach(function (s) {
|
||||
var a = project(s.a[0], s.a[1], crs), b = project(s.b[0], s.b[1], crs);
|
||||
L.polyline([a, b], { color: s.k || '#30b0c7', weight: 2, opacity: 0.55 })
|
||||
.bindTooltip((s.m || '行程') + (s.km ? ' · ' + s.km.toFixed(1) + ' km' : ''), { sticky: true })
|
||||
.addTo(g);
|
||||
});
|
||||
return g;
|
||||
}
|
||||
function buildHeat(crs) {
|
||||
// 必须用 st.points:本页首屏是用 points: [] 构造的(地点随后经 /api/points 拉回),
|
||||
// setPoints 只更新了 st.points,闭包里那个 points 一直是空数组 —— 用它热力图恒为空。
|
||||
var pts = (st.points || []).map(function (p) {
|
||||
var c = project(p.lat, p.lon, crs);
|
||||
return [c[0], c[1], Math.min(p.v || 1, 25)];
|
||||
}).filter(function (c) { return c[0] || c[1]; });
|
||||
return L.heatLayer(pts, { radius: 22, blur: 18, maxZoom: 17, minOpacity: 0.35 });
|
||||
}
|
||||
|
||||
window.rondMap.setTrack = function (segs, modes) {
|
||||
if (trackLayer) { map.removeLayer(trackLayer); trackLayer = null; }
|
||||
if (segs && segs.length) {
|
||||
trackSegs = segs;
|
||||
trackLayer = buildTrack(segs, st.crs).addTo(map);
|
||||
} else {
|
||||
trackSegs = null;
|
||||
}
|
||||
// 交通方式图例:告诉用户哪种颜色是哪类出行
|
||||
if (modeLegend) { map.removeControl(modeLegend); modeLegend = null; }
|
||||
if (modes && modes.length && segs && segs.length) {
|
||||
modeLegend = modeLegendControl(modes);
|
||||
modeLegend.addTo(map);
|
||||
}
|
||||
};
|
||||
|
||||
// 城市级聚合:圆圈大小随到访次数增长,气泡里带「首次到达」里程碑
|
||||
window.rondMap.setCityAgg = function (on) {
|
||||
if (cityLayer) { map.removeLayer(cityLayer); cityLayer = null; }
|
||||
if (!on) return;
|
||||
fetch('/api/cities').then(function (r) { return r.json(); }).then(function (d) {
|
||||
var cities = d.cities || [];
|
||||
if (!cities.length) return;
|
||||
var max = Math.max.apply(null, cities.map(function (c) { return c.visits || 1; }));
|
||||
cityLayer = L.layerGroup();
|
||||
cities.forEach(function (c) {
|
||||
var pos = project(c.lat, c.lon, st.crs);
|
||||
var r = 8 + 22 * Math.sqrt((c.visits || 1) / max);
|
||||
L.circleMarker(pos, {
|
||||
radius: r, color: '#2f7d6e', weight: 1.5,
|
||||
fillColor: '#2f7d6e', fillOpacity: 0.18
|
||||
}).bindPopup('<b>' + c.city + '</b>' +
|
||||
(c.province ? ' · ' + c.province : '') +
|
||||
'<br>到访 ' + c.visits + ' 次 · ' + c.places + ' 个地点' +
|
||||
(c.first ? '<br>首次到达 ' + c.first : '')).addTo(cityLayer);
|
||||
});
|
||||
cityLayer.addTo(map);
|
||||
});
|
||||
};
|
||||
|
||||
// GPX 轨迹:库里是 WGS-84,服务端已按当前底图坐标系转换好
|
||||
window.rondMap.setGpx = function (on) {
|
||||
if (gpxLayer) { map.removeLayer(gpxLayer); gpxLayer = null; }
|
||||
if (!on) return;
|
||||
ensureGpx(st.crs, function (tracks) {
|
||||
gpxLayer = L.layerGroup();
|
||||
tracks.forEach(function (t) {
|
||||
if (!t.points || t.points.length < 2) return;
|
||||
L.polyline(t.points, { color: '#e8590c', weight: 3, opacity: 0.8 })
|
||||
.bindTooltip(t.name + (t.km ? ' · ' + t.km.toFixed(1) + ' km' : ''), { sticky: true })
|
||||
.addTo(gpxLayer);
|
||||
});
|
||||
gpxLayer.addTo(map);
|
||||
});
|
||||
};
|
||||
window.rondMap.setHeat = function (on) {
|
||||
if (heatLayer) { map.removeLayer(heatLayer); heatLayer = null; }
|
||||
if (!on) return;
|
||||
// heat 库只在第一次开热力图时才下载,平时不进首屏
|
||||
ensureHeat(function () {
|
||||
if (L.heatLayer) heatLayer = buildHeat(st.crs).addTo(map);
|
||||
});
|
||||
};
|
||||
|
||||
map.on('baselayerchange', function (e) {
|
||||
if (e.layer && e.layer.__crs) {
|
||||
active = e.layer;
|
||||
errCount = 0;
|
||||
st.apply(e.layer.__crs);
|
||||
if (fogLayer && map.hasLayer(fogLayer)) {
|
||||
fogLayer.setUrl('/tiles/fog/{z}/{x}/{y}?crs=' + e.layer.__crs + '&v=' + cfg.fog.ver);
|
||||
}
|
||||
if (trackLayer && trackSegs) {
|
||||
map.removeLayer(trackLayer);
|
||||
trackLayer = buildTrack(trackSegs, e.layer.__crs).addTo(map);
|
||||
}
|
||||
if (heatLayer) {
|
||||
map.removeLayer(heatLayer);
|
||||
heatLayer = buildHeat(e.layer.__crs).addTo(map);
|
||||
}
|
||||
if (cityLayer) { map.removeLayer(cityLayer); cityLayer = null; window.rondMap.setCityAgg(true); }
|
||||
if (gpxLayer) { map.removeLayer(gpxLayer); gpxLayer = null; window.rondMap.setGpx(true); }
|
||||
}
|
||||
});
|
||||
|
||||
// byId 记住「点 → 图层」,setPoints 才能只补差集;
|
||||
// max 是当前这批里最多的到访次数,用于点的半径归一化
|
||||
st.byId = {};
|
||||
st.max = 1;
|
||||
|
||||
window.rondMap.setPoints = function (pts, opts) {
|
||||
opts = opts || {};
|
||||
var prev = st.byId || {};
|
||||
var max = 1;
|
||||
pts.forEach(function (p) { if (p.v > max) max = p.v; });
|
||||
|
||||
// 数据是重新拉的(对象不是同一个)就整体重建:挂着的 popup 里存的是旧对象,
|
||||
// 复用会导致打开气泡看到的还是上一次筛选的数字。
|
||||
// 时间轴只是在这批点上做子集筛选,对象是同一批,因此能安全走增量。
|
||||
var refetch = pts.some(function (p) { return prev[p.id] && prev[p.id].__p !== p; });
|
||||
if (refetch || Object.keys(prev).length === 0) {
|
||||
points = st.points = pts;
|
||||
map.removeLayer(st.group);
|
||||
st.byId = {};
|
||||
st.group = markers(pts, st.crs, true, true, st.byId);
|
||||
st.group.addTo(map);
|
||||
st.max = max;
|
||||
fit(map, pts, st.crs);
|
||||
return;
|
||||
}
|
||||
|
||||
var next = {};
|
||||
var added = [];
|
||||
pts.forEach(function (p) {
|
||||
var m = prev[p.id];
|
||||
if (m) {
|
||||
next[p.id] = m;
|
||||
delete prev[p.id];
|
||||
return;
|
||||
}
|
||||
m = makeMarker(p, st.crs, max, true);
|
||||
next[p.id] = m;
|
||||
added.push(m);
|
||||
st.group.addLayer(m);
|
||||
});
|
||||
// 剩下的就是这一批里没有的点,逐个移除(不重建,其余点纹丝不动)
|
||||
Object.keys(prev).forEach(function (id) { st.group.removeLayer(prev[id]); });
|
||||
// 到访次数上限变了就顺手调一下半径,免得新旧点大小不一致
|
||||
if (max !== st.max) {
|
||||
pts.forEach(function (p) {
|
||||
var m = next[p.id];
|
||||
if (m && m.setRadius) m.setRadius(radiusOf(p, max));
|
||||
});
|
||||
st.max = max;
|
||||
}
|
||||
st.byId = next;
|
||||
points = st.points = pts;
|
||||
animateIn(added);
|
||||
st.clusterIcons = clusterState(map, st.clusterIcons);
|
||||
// 时间轴拖动的每一帧都重新 fit 会让地图不停缩放,很晕;只有换筛选时才 fit
|
||||
if (!opts.keepView) fit(map, pts, st.crs);
|
||||
};
|
||||
window.addEventListener('resize', function () { map.invalidateSize(); });
|
||||
});
|
||||
|
||||
return map;
|
||||
},
|
||||
|
||||
_state: {}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "我的足迹地图",
|
||||
"short_name": "足迹",
|
||||
"description": "rond 备份驱动的个人足迹地图",
|
||||
"lang": "zh-CN",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f7f8f8",
|
||||
"theme_color": "#2f7d6e",
|
||||
"icons": [
|
||||
{ "src": "/static/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||
{ "src": "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
(function (global, factory) {
|
||||
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
|
||||
typeof define === 'function' && define.amd ? define(['exports'], factory) :
|
||||
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.zh = {}));
|
||||
}(this, (function (exports) { 'use strict';
|
||||
|
||||
var fp = typeof window !== "undefined" && window.flatpickr !== undefined
|
||||
? window.flatpickr
|
||||
: {
|
||||
l10ns: {},
|
||||
};
|
||||
var Mandarin = {
|
||||
weekdays: {
|
||||
shorthand: ["周日", "周一", "周二", "周三", "周四", "周五", "周六"],
|
||||
longhand: [
|
||||
"星期日",
|
||||
"星期一",
|
||||
"星期二",
|
||||
"星期三",
|
||||
"星期四",
|
||||
"星期五",
|
||||
"星期六",
|
||||
],
|
||||
},
|
||||
months: {
|
||||
shorthand: [
|
||||
"一月",
|
||||
"二月",
|
||||
"三月",
|
||||
"四月",
|
||||
"五月",
|
||||
"六月",
|
||||
"七月",
|
||||
"八月",
|
||||
"九月",
|
||||
"十月",
|
||||
"十一月",
|
||||
"十二月",
|
||||
],
|
||||
longhand: [
|
||||
"一月",
|
||||
"二月",
|
||||
"三月",
|
||||
"四月",
|
||||
"五月",
|
||||
"六月",
|
||||
"七月",
|
||||
"八月",
|
||||
"九月",
|
||||
"十月",
|
||||
"十一月",
|
||||
"十二月",
|
||||
],
|
||||
},
|
||||
rangeSeparator: " 至 ",
|
||||
weekAbbreviation: "周",
|
||||
scrollTitle: "滚动切换",
|
||||
toggleTitle: "点击切换 12/24 小时时制",
|
||||
};
|
||||
fp.l10ns.zh = Mandarin;
|
||||
var zh = fp.l10ns;
|
||||
|
||||
exports.Mandarin = Mandarin;
|
||||
exports.default = zh;
|
||||
|
||||
Object.defineProperty(exports, '__esModule', { value: true });
|
||||
|
||||
})));
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
(c) 2014, Vladimir Agafonkin
|
||||
simpleheat, a tiny JavaScript library for drawing heatmaps with Canvas
|
||||
https://github.com/mourner/simpleheat
|
||||
*/
|
||||
!function(){"use strict";function t(i){return this instanceof t?(this._canvas=i="string"==typeof i?document.getElementById(i):i,this._ctx=i.getContext("2d"),this._width=i.width,this._height=i.height,this._max=1,void this.clear()):new t(i)}t.prototype={defaultRadius:25,defaultGradient:{.4:"blue",.6:"cyan",.7:"lime",.8:"yellow",1:"red"},data:function(t,i){return this._data=t,this},max:function(t){return this._max=t,this},add:function(t){return this._data.push(t),this},clear:function(){return this._data=[],this},radius:function(t,i){i=i||15;var a=this._circle=document.createElement("canvas"),s=a.getContext("2d"),e=this._r=t+i;return a.width=a.height=2*e,s.shadowOffsetX=s.shadowOffsetY=200,s.shadowBlur=i,s.shadowColor="black",s.beginPath(),s.arc(e-200,e-200,t,0,2*Math.PI,!0),s.closePath(),s.fill(),this},gradient:function(t){var i=document.createElement("canvas"),a=i.getContext("2d"),s=a.createLinearGradient(0,0,0,256);i.width=1,i.height=256;for(var e in t)s.addColorStop(e,t[e]);return a.fillStyle=s,a.fillRect(0,0,1,256),this._grad=a.getImageData(0,0,1,256).data,this},draw:function(t){this._circle||this.radius(this.defaultRadius),this._grad||this.gradient(this.defaultGradient);var i=this._ctx;i.clearRect(0,0,this._width,this._height);for(var a,s=0,e=this._data.length;e>s;s++)a=this._data[s],i.globalAlpha=Math.max(a[2]/this._max,t||.05),i.drawImage(this._circle,a[0]-this._r,a[1]-this._r);var n=i.getImageData(0,0,this._width,this._height);return this._colorize(n.data,this._grad),i.putImageData(n,0,0),this},_colorize:function(t,i){for(var a,s=3,e=t.length;e>s;s+=4)a=4*t[s],a&&(t[s-3]=i[a],t[s-2]=i[a+1],t[s-1]=i[a+2])}},window.simpleheat=t}(),/*
|
||||
(c) 2014, Vladimir Agafonkin
|
||||
Leaflet.heat, a tiny and fast heatmap plugin for Leaflet.
|
||||
https://github.com/Leaflet/Leaflet.heat
|
||||
*/
|
||||
L.HeatLayer=(L.Layer?L.Layer:L.Class).extend({initialize:function(t,i){this._latlngs=t,L.setOptions(this,i)},setLatLngs:function(t){return this._latlngs=t,this.redraw()},addLatLng:function(t){return this._latlngs.push(t),this.redraw()},setOptions:function(t){return L.setOptions(this,t),this._heat&&this._updateOptions(),this.redraw()},redraw:function(){return!this._heat||this._frame||this._map._animating||(this._frame=L.Util.requestAnimFrame(this._redraw,this)),this},onAdd:function(t){this._map=t,this._canvas||this._initCanvas(),t._panes.overlayPane.appendChild(this._canvas),t.on("moveend",this._reset,this),t.options.zoomAnimation&&L.Browser.any3d&&t.on("zoomanim",this._animateZoom,this),this._reset()},onRemove:function(t){t.getPanes().overlayPane.removeChild(this._canvas),t.off("moveend",this._reset,this),t.options.zoomAnimation&&t.off("zoomanim",this._animateZoom,this)},addTo:function(t){return t.addLayer(this),this},_initCanvas:function(){var t=this._canvas=L.DomUtil.create("canvas","leaflet-heatmap-layer leaflet-layer"),i=L.DomUtil.testProp(["transformOrigin","WebkitTransformOrigin","msTransformOrigin"]);t.style[i]="50% 50%";var a=this._map.getSize();t.width=a.x,t.height=a.y;var s=this._map.options.zoomAnimation&&L.Browser.any3d;L.DomUtil.addClass(t,"leaflet-zoom-"+(s?"animated":"hide")),this._heat=simpleheat(t),this._updateOptions()},_updateOptions:function(){this._heat.radius(this.options.radius||this._heat.defaultRadius,this.options.blur),this.options.gradient&&this._heat.gradient(this.options.gradient),this.options.max&&this._heat.max(this.options.max)},_reset:function(){var t=this._map.containerPointToLayerPoint([0,0]);L.DomUtil.setPosition(this._canvas,t);var i=this._map.getSize();this._heat._width!==i.x&&(this._canvas.width=this._heat._width=i.x),this._heat._height!==i.y&&(this._canvas.height=this._heat._height=i.y),this._redraw()},_redraw:function(){var t,i,a,s,e,n,h,o,r,d=[],_=this._heat._r,l=this._map.getSize(),m=new L.Bounds(L.point([-_,-_]),l.add([_,_])),c=void 0===this.options.max?1:this.options.max,u=void 0===this.options.maxZoom?this._map.getMaxZoom():this.options.maxZoom,f=1/Math.pow(2,Math.max(0,Math.min(u-this._map.getZoom(),12))),g=_/2,p=[],v=this._map._getMapPanePos(),w=v.x%g,y=v.y%g;for(t=0,i=this._latlngs.length;i>t;t++)if(a=this._map.latLngToContainerPoint(this._latlngs[t]),m.contains(a)){e=Math.floor((a.x-w)/g)+2,n=Math.floor((a.y-y)/g)+2;var x=void 0!==this._latlngs[t].alt?this._latlngs[t].alt:void 0!==this._latlngs[t][2]?+this._latlngs[t][2]:1;r=x*f,p[n]=p[n]||[],s=p[n][e],s?(s[0]=(s[0]*s[2]+a.x*r)/(s[2]+r),s[1]=(s[1]*s[2]+a.y*r)/(s[2]+r),s[2]+=r):p[n][e]=[a.x,a.y,r]}for(t=0,i=p.length;i>t;t++)if(p[t])for(h=0,o=p[t].length;o>h;h++)s=p[t][h],s&&d.push([Math.round(s[0]),Math.round(s[1]),Math.min(s[2],c)]);this._heat.data(d).draw(this.options.minOpacity),this._frame=null},_animateZoom:function(t){var i=this._map.getZoomScale(t.zoom),a=this._map._getCenterOffset(t.center)._multiplyBy(-i).subtract(this._map._getMapPanePos());L.DomUtil.setTransform?L.DomUtil.setTransform(this._canvas,a,i):this._canvas.style[L.DomUtil.TRANSFORM]=L.DomUtil.getTranslateString(a)+" scale("+i+")"}}),L.heatLayer=function(t,i){return new L.HeatLayer(t,i)};
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 696 B |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 618 B |
@@ -0,0 +1,661 @@
|
||||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
.marker-cluster-small {
|
||||
background-color: rgba(181, 226, 140, 0.6);
|
||||
}
|
||||
.marker-cluster-small div {
|
||||
background-color: rgba(110, 204, 57, 0.6);
|
||||
}
|
||||
|
||||
.marker-cluster-medium {
|
||||
background-color: rgba(241, 211, 87, 0.6);
|
||||
}
|
||||
.marker-cluster-medium div {
|
||||
background-color: rgba(240, 194, 12, 0.6);
|
||||
}
|
||||
|
||||
.marker-cluster-large {
|
||||
background-color: rgba(253, 156, 115, 0.6);
|
||||
}
|
||||
.marker-cluster-large div {
|
||||
background-color: rgba(241, 128, 23, 0.6);
|
||||
}
|
||||
|
||||
/* IE 6-8 fallback colors */
|
||||
.leaflet-oldie .marker-cluster-small {
|
||||
background-color: rgb(181, 226, 140);
|
||||
}
|
||||
.leaflet-oldie .marker-cluster-small div {
|
||||
background-color: rgb(110, 204, 57);
|
||||
}
|
||||
|
||||
.leaflet-oldie .marker-cluster-medium {
|
||||
background-color: rgb(241, 211, 87);
|
||||
}
|
||||
.leaflet-oldie .marker-cluster-medium div {
|
||||
background-color: rgb(240, 194, 12);
|
||||
}
|
||||
|
||||
.leaflet-oldie .marker-cluster-large {
|
||||
background-color: rgb(253, 156, 115);
|
||||
}
|
||||
.leaflet-oldie .marker-cluster-large div {
|
||||
background-color: rgb(241, 128, 23);
|
||||
}
|
||||
|
||||
.marker-cluster {
|
||||
background-clip: padding-box;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.marker-cluster div {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-left: 5px;
|
||||
margin-top: 5px;
|
||||
|
||||
text-align: center;
|
||||
border-radius: 15px;
|
||||
font: 12px "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
}
|
||||
.marker-cluster span {
|
||||
line-height: 30px;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
.leaflet-cluster-anim .leaflet-marker-icon, .leaflet-cluster-anim .leaflet-marker-shadow {
|
||||
-webkit-transition: -webkit-transform 0.3s ease-out, opacity 0.3s ease-in;
|
||||
-moz-transition: -moz-transform 0.3s ease-out, opacity 0.3s ease-in;
|
||||
-o-transition: -o-transform 0.3s ease-out, opacity 0.3s ease-in;
|
||||
transition: transform 0.3s ease-out, opacity 0.3s ease-in;
|
||||
}
|
||||
|
||||
.leaflet-cluster-spider-leg {
|
||||
/* stroke-dashoffset (duration and function) should match with leaflet-marker-icon transform in order to track it exactly */
|
||||
-webkit-transition: -webkit-stroke-dashoffset 0.3s ease-out, -webkit-stroke-opacity 0.3s ease-in;
|
||||
-moz-transition: -moz-stroke-dashoffset 0.3s ease-out, -moz-stroke-opacity 0.3s ease-in;
|
||||
-o-transition: -o-stroke-dashoffset 0.3s ease-out, -o-stroke-opacity 0.3s ease-in;
|
||||
transition: stroke-dashoffset 0.3s ease-out, stroke-opacity 0.3s ease-in;
|
||||
}
|
||||