소스 검색

feat: 校区地图显示省市名称,学校点按门店数分级着色

- 底图常显省份简称(内蒙古/黑龙江/浙江…),放大到 2 倍后显示城市/区县名称
- 学校点位颜色按关联门店数分级:0 灰、1-3 浅橙、4-9 橙、10+ 深橙,右侧列表圆点同步
- 地图左下角加图例,说明颜色区间与「停用/未定位」样式
liangzhao 2 주 전
부모
커밋
b133453ba8
3개의 변경된 파일과 99개의 추가작업 그리고 6개의 파일을 삭제
  1. 1 1
      public/index.html
  2. 90 5
      public/schools.js
  3. 8 0
      public/style.css

+ 1 - 1
public/index.html

@@ -343,7 +343,7 @@
           </div>
         </div>
         <div class="schools-layout">
-          <div class="schools-map-card"><div id="schoolsMap" class="schools-map"></div></div>
+          <div class="schools-map-card"><div id="schoolsMap" class="schools-map"></div><div id="schoolsLegend" class="schools-legend"></div></div>
           <aside class="schools-side">
             <div class="schools-side-head"><b>学校</b><span id="schoolsCount"></span></div>
             <div id="schoolsList" class="schools-list"></div>

+ 90 - 5
public/schools.js

@@ -9,6 +9,8 @@
 
   let chart = null;
   let schools = [];
+  let cityPoints = [];
+  let cityLabelsVisible = false;
   let inited = false;
   let loading = false;
   let selectedId = null;
@@ -30,11 +32,42 @@
     return `${school.ops.shopCount} 店${school.ops.lastDate ? ' · 最近 ' + fmtDate(school.ops.lastDate) : ''}`;
   }
 
+  function shortProvince(name) {
+    const raw = String(name || '');
+    const short = raw
+      .replace(/特别行政区$/, '')
+      .replace(/(壮族|回族|维吾尔)?自治区$/, '')
+      .replace(/省$/, '')
+      .replace(/市$/, '');
+    return short || raw;
+  }
+
+  /** 门店数越多颜色越深,用来区分学校的经营体量 */
+  function shopColor(count) {
+    const n = Number(count) || 0;
+    if (n >= 10) return '#e8501f';
+    if (n >= 4) return '#ff8a5c';
+    if (n >= 1) return '#ffb894';
+    return '#d8d1c9';
+  }
+
+  function buildCityPoints(data) {
+    const points = [];
+    for (const info of Object.values(data || {})) {
+      const cities = (info && info.cities) || {};
+      for (const [name, coord] of Object.entries(cities)) {
+        if (Array.isArray(coord) && coord.length === 2) points.push({ name, value: coord });
+      }
+    }
+    return points;
+  }
+
   function toPoint(school) {
     return {
       name: school.name,
       value: [school.lng, school.lat],
       schoolId: school.id,
+      itemStyle: { color: shopColor(school.ops && school.ops.shopCount) },
     };
   }
 
@@ -64,10 +97,30 @@
         zoom: ZOOM,
         scaleLimit: { min: 1, max: 12 },
         itemStyle: { areaColor: '#f7f5f2', borderColor: '#ddd6cd', borderWidth: 0.8 },
-        emphasis: { itemStyle: { areaColor: '#efeae4' }, label: { show: false } },
+        label: { show: true, color: '#8f8478', fontSize: 10, formatter: (p) => shortProvince(p.name) },
+        emphasis: { itemStyle: { areaColor: '#efeae4' }, label: { show: true, color: '#5b534c', fontSize: 10 } },
         select: { disabled: true },
       },
       series: [
+        {
+          id: 'cities',
+          type: 'scatter',
+          coordinateSystem: 'geo',
+          data: cityPoints,
+          symbolSize: 2,
+          itemStyle: { color: '#d5cdc4' },
+          label: {
+            show: cityLabelsVisible,
+            position: 'right',
+            distance: 2,
+            fontSize: 9,
+            color: '#a39a91',
+            formatter: (p) => p.name,
+          },
+          labelLayout: { hideOverlap: true },
+          silent: true,
+          zlevel: 0,
+        },
         {
           id: 'on',
           type: 'effectScatter',
@@ -76,7 +129,7 @@
           symbolSize: 11,
           showEffectOn: 'render',
           rippleEffect: { brushType: 'stroke', scale: 3.4, period: 3.6 },
-          itemStyle: { color: '#ff6b35', shadowBlur: 8, shadowColor: 'rgba(255,107,53,.45)' },
+          itemStyle: { shadowBlur: 8, shadowColor: 'rgba(255,107,53,.35)' },
           label: {
             show: true,
             position: 'right',
@@ -102,11 +155,39 @@
     };
   }
 
+  function renderLegend() {
+    const box = $('schoolsLegend');
+    if (!box) return;
+    const hasOps = schools.some((s) => s.ops && s.ops.shopCount > 0);
+    if (!hasOps) {
+      box.innerHTML = '';
+      return;
+    }
+    box.innerHTML = '<span class="schools-legend-title">门店数</span>'
+      + '<span><i style="background:#d8d1c9"></i>0</span>'
+      + '<span><i style="background:#ffb894"></i>1-3</span>'
+      + '<span><i style="background:#ff8a5c"></i>4-9</span>'
+      + '<span><i style="background:#e8501f"></i>10+</span>'
+      + '<span class="schools-legend-sep"></span>'
+      + '<span><i class="hollow"></i>停用 / 未定位</span>';
+  }
+
   function renderMap(force) {
     const box = $('schoolsMap');
     if (!box) return;
-    if (!chart) chart = echarts.init(box);
+    if (!chart) {
+      chart = echarts.init(box);
+      chart.on('georoam', () => {
+        const option = chart.getOption();
+        const zoom = (option.geo && option.geo[0] && option.geo[0].zoom) || ZOOM;
+        const shouldShow = zoom >= 2;
+        if (shouldShow === cityLabelsVisible) return;
+        cityLabelsVisible = shouldShow;
+        chart.setOption({ series: [{ id: 'cities', label: { show: shouldShow } }] });
+      });
+    }
     chart.setOption(buildOption(), !!force);
+    renderLegend();
   }
 
   function renderList(data) {
@@ -133,7 +214,7 @@
       const rows = items.map((s) => `
         <div class="schools-item${s.id === selectedId ? ' on' : ''}" data-id="${esc(s.id)}">
           <div class="schools-item-name">
-            <span class="schools-dot${s.enabled && s.lng != null ? '' : ' off'}"></span>
+            <span class="schools-dot${s.enabled && s.lng != null ? '' : ' off'}"${s.enabled && s.lng != null ? ` style="background:${shopColor(s.ops && s.ops.shopCount)}"` : ''}></span>
             <span>${esc(s.name)}</span>
             ${s.seed ? '<span class="badge badge-muted">内置</span>' : ''}
             ${s.lng == null ? '<span class="badge badge-warn">未定位</span>' : ''}
@@ -176,14 +257,18 @@
     if (loading) return;
     loading = true;
     try {
-      const [geoRes, listRes] = await Promise.all([
+      const [geoRes, listRes, cityRes] = await Promise.all([
         fetch('/vendor/china-map.json'),
         fetch('/api/schools'),
+        fetch('/vendor/cn-cities.json').catch(() => null),
       ]);
       if (!geoRes.ok) throw new Error('底图加载失败');
       const geo = await geoRes.json();
       const data = await listRes.json();
       echarts.registerMap('china', geo);
+      if (cityRes && cityRes.ok) {
+        try { cityPoints = buildCityPoints(await cityRes.json()); } catch { cityPoints = []; }
+      }
       schools = data.schools || [];
       renderList(data);
       renderMap(true);

+ 8 - 0
public/style.css

@@ -1470,3 +1470,11 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 .rp-toolbar { flex-wrap: wrap; }
 .rp-toolbar .ops-multi { min-width: 120px; }
 .rp-page.rp-stale { opacity: .55; transition: opacity .15s ease; }
+
+/* 校区地图:省名标签 + 门店数图例 */
+.schools-map-card { position: relative; }
+.schools-legend { position: absolute; left: 14px; bottom: 12px; display: flex; align-items: center; gap: 10px; padding: 6px 10px; font-size: 11px; color: var(--text-2); background: rgba(255,255,255,.92); border: 1px solid var(--border-light); border-radius: 8px; backdrop-filter: blur(4px); }
+.schools-legend i { display: inline-block; width: 8px; height: 8px; margin-right: 4px; border-radius: 50%; vertical-align: 0; }
+.schools-legend i.hollow { background: #fff; border: 1.5px solid #b8b2ad; }
+.schools-legend-title { color: var(--text-3); }
+.schools-legend-sep { width: 1px; height: 12px; background: var(--border); }