---
title: "地图 + 筛选侧栏"
summary: "左侧多段筛选、右侧 Leaflet 地图：结果列表和标记实时同步，点聚合只数符合条件的点，点列表飞到地图并弹详情。"
category: "layout (整页与区块)"
origin: "original"
stack: ["html","css","javascript","leaflet"]
tags: ["地图","筛选面板","点聚合","列表联动","工具页"]
demo: "https://zhuoqidev.com/design/demos/map-filter-finder/"
url: "https://zhuoqidev.com/design/map-filter-finder/"
license: "MIT"
added: "2026-10-14"
translation: "https://zhuoqidev.com/en/design/map-filter-finder/index.md"
---

# 地图 + 筛选侧栏

> 左侧多段筛选、右侧 Leaflet 地图：结果列表和标记实时同步，点聚合只数符合条件的点，点列表飞到地图并弹详情。

演示数据是虚构的 44 家咖啡馆，店名、位置、评分都是编的；底图来自 OpenStreetMap。

## 看点

- **一个 state、一次 render**：类型、评分、营业时间、设施、搜索都只改 `state`，然后 `render()` 用同一个 `pass()` 同时算出列表和地图标记，两边永远对得上。
- **不符合的变灰或隐藏**：可选「在地图上变灰」（保留空间感，知道哪里还有店）或「从地图上隐藏」。
- **点聚合只数符合的**：`leaflet.markercluster` 的 `iconCreateFunction` 里只数通过筛选的子标记，气泡上的数字和列表条数一致；全都不符合的聚合点变灰。
- **列表 ↔ 地图联动**：悬停列表行，对应标记（或它所在的聚合点）放大描边；点列表行用 `zoomToShowLayer` 展开聚合、再弹出详情；点地图标记，列表滚到那一行并高亮。
- **点地图设起点**：点任意位置放一个「起」标记，排序自动切到「离起点最近」，列表和弹窗都显示距离（`map.distance`）。
- **深色地图不用第二套瓦片**：瓦片层加 `filter: invert(1) hue-rotate(180deg)` 再降饱和；浅色模式也压一点灰，让彩色标记更跳。
- **窄屏变抽屉**：760px 以下筛选面板变成左侧抽屉，地图上浮一个「筛选 · 29」按钮，数字跟着结果变。

## 怎么用

1. 引入 Leaflet 和 markercluster 的 CSS / JS（`leaflet@1.9.4`、`leaflet.markercluster@1.5.3`），拷页面结构和样式。
2. 把 `data.js` 换成你的数据，每条至少有 `id`、`name`、`lat`、`lng`；在 `KINDS`、`FEATS` 里改分类和设施名，`pass()` 里改筛选规则。
3. 改 `setView` 的中心和缩放；`popupHtml()` 决定详情里显示什么。
4. 数据从接口来时，拿到后赋给 `cafes` 再调一次 `render()` 即可。

## 坑

- 示例直连 `tile.openstreetmap.org`，只适合低流量演示。OSM 瓦片使用政策要求标注「© OpenStreetMap contributors」、禁止批量下载和离线缓存，流量大了要换商业瓦片服务或自建。
- 国内一些地图服务用 GCJ-02 坐标，OSM 用 WGS-84，两套直接混用会偏几百米。数据和底图要用同一套坐标系。
- `markerClusterGroup` 每次 `clearLayers()` 再 `addLayers()` 比逐个增删快很多；几十个点无所谓，上千个点一定要批量加。
- 标记在聚合里时 `marker.getElement()` 是 `null`，高亮要先 `getVisibleParent()` 找到实际显示的那个聚合点。
- 抽屉、侧栏里的地图在容器尺寸变化后要调 `map.invalidateSize()`；本例地图区域尺寸固定，所以没用到。

## 参考实现（完整源码）

在线演示：https://zhuoqidev.com/design/demos/map-filter-finder/。共 2 个文件，第一个是演示页本身。

### index.html

```html
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>地图 + 筛选侧栏</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css" />
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
    <style>
      :root {
        --paper: #fbf8f1;
        --paper-2: #f3eee2;
        --card: #fffdf8;
        --ink: #1c2230;
        --ink-2: #4a5163;
        --ink-3: #636877;
        --line: #e6dfd0;
        --dot: #d8d0bf;
        --blue: #2f5bd3;
        --blue-bg: #e8edfb;
        --on-blue: #fff;
        --seal: #c0391f;
        --hi: #ffe066;
        /* one color per café type; markers, chips and legend all use these */
        --k-hand: #2f5bd3;
        --k-bake: #c8661c;
        --k-chain: #1f8a70;
        --k-tea: #8a5bb0;
        --map-bg: #ece6d8;
        --tiles: grayscale(0.7) sepia(0.12) brightness(1.03) contrast(0.92);
        --f-sans: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
        --f-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
        color-scheme: light;
      }
      @media (prefers-color-scheme: dark) {
        :root {
          --paper: #14161b;
          --paper-2: #1c1f26;
          --card: #1e2129;
          --ink: #ece7dc;
          --ink-2: #bab6ac;
          --ink-3: #959aa8;
          --line: #31353f;
          --dot: #2c3039;
          --blue: #8eaaff;
          --blue-bg: #1f2840;
          --on-blue: #14161b;
          --seal: #f0694e;
          --hi: #7d6416;
          --k-hand: #8eaaff;
          --k-bake: #ec9550;
          --k-chain: #4cc3a0;
          --k-tea: #bd96e0;
          --map-bg: #1a1d23;
          /* Dark tiles without a dark tile server: invert, then turn the hues back. */
          --tiles: invert(1) hue-rotate(180deg) grayscale(0.55) brightness(0.88) contrast(0.9);
          color-scheme: dark;
        }
      }
      * {
        box-sizing: border-box;
      }
      html,
      body {
        height: 100%;
        margin: 0;
      }
      body {
        background: var(--paper);
        color: var(--ink);
        font: 13px/1.55 var(--f-sans);
      }
      button,
      input,
      select {
        font: inherit;
        color: inherit;
      }
      #app {
        display: flex;
        height: 100%;
        overflow: hidden;
      }

      /* ---------- the filter panel ---------- */
      #panel {
        display: flex;
        flex-direction: column;
        flex: none;
        width: 340px;
        height: 100%;
        overflow-y: auto;
        background: var(--paper) radial-gradient(var(--dot) 1px, transparent 1.2px) 0 0 / 22px 22px;
        border-right: 1px solid var(--line);
        z-index: 1000;
      }
      .head {
        position: relative;
        padding: 14px 16px 10px;
        border-bottom: 1px solid var(--line);
      }
      .head h1 {
        margin: 0;
        font-size: 17px;
        letter-spacing: 0.02em;
      }
      .head .sub {
        margin: 2px 0 0;
        color: var(--ink-2);
        font-size: 12px;
      }
      .stamp {
        display: inline-block;
        margin-right: 6px;
        padding: 0 5px;
        border: 1.5px solid var(--seal);
        border-radius: 4px;
        color: var(--seal);
        font-size: 11px;
        font-weight: 600;
      }
      .icon-btn {
        position: absolute;
        top: 8px;
        right: 8px;
        display: none;
        width: 32px;
        height: 32px;
        border: 0;
        border-radius: 8px;
        background: none;
        color: var(--ink-3);
        font-size: 20px;
        cursor: pointer;
      }
      .block {
        padding: 10px 16px 12px;
        border-bottom: 1px solid var(--line);
      }
      .block h2 {
        margin: 0 0 6px;
        color: var(--ink-2);
        font-size: 12px;
        font-weight: 650;
        letter-spacing: 0.04em;
      }
      .hint {
        margin: 4px 0 0;
        color: var(--ink-3);
        font-size: 12px;
      }
      input[type="search"],
      select {
        border: 1px solid var(--line);
        border-radius: 8px;
        background: var(--card);
        padding: 5px 8px;
      }
      input[type="search"] {
        width: 100%;
        padding: 7px 10px;
      }
      input[type="range"] {
        width: 100%;
        margin: 2px 0;
        accent-color: var(--blue);
      }
      .row {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 8px;
        margin-top: 8px;
      }
      .row:first-of-type {
        margin-top: 0;
      }
      .val {
        font-family: var(--f-mono);
        color: var(--ink-2);
      }
      .chips {
        display: flex;
        flex-wrap: wrap;
        gap: 6px;
      }
      .chip {
        display: inline-flex;
        align-items: center;
        gap: 6px;
        padding: 3px 10px;
        border: 1px solid var(--line);
        border-radius: 999px;
        background: var(--card);
        font-size: 12px;
        cursor: pointer;
        transition:
          background 0.2s,
          border-color 0.2s;
      }
      .chip:hover {
        border-color: var(--blue);
      }
      .chip[aria-pressed="true"] {
        background: var(--blue-bg);
        border-color: var(--blue);
      }
      .chip[aria-pressed="false"] {
        color: var(--ink-3);
      }
      .chip[aria-pressed="false"] .sw {
        opacity: 0.35;
      }
      .sw {
        width: 9px;
        height: 9px;
        border-radius: 50%;
        flex: none;
      }
      .checks {
        display: grid;
        grid-template-columns: 1fr 1fr;
        gap: 0 8px;
      }
      label.check {
        display: flex;
        align-items: center;
        gap: 6px;
        padding: 2px 0;
        cursor: pointer;
      }
      label.check input {
        accent-color: var(--blue);
      }

      /* ---------- result list (synced with the map) ---------- */
      .results {
        flex: 1;
      }
      #list {
        margin: 6px 0 0;
        padding: 0;
        list-style: none;
        border: 1px solid var(--line);
        border-radius: 10px;
        background: var(--card);
        overflow: hidden;
      }
      #list li + li {
        border-top: 1px solid var(--line);
      }
      #list button {
        display: grid;
        grid-template-columns: 22px 1fr auto;
        gap: 2px 8px;
        width: 100%;
        padding: 7px 10px;
        border: 0;
        background: none;
        text-align: left;
        cursor: pointer;
      }
      #list button:hover,
      #list button:focus-visible {
        background: var(--paper-2);
        outline: none;
      }
      #list button[aria-current="true"] {
        background: var(--blue-bg);
        box-shadow: inset 3px 0 0 var(--blue);
      }
      #list .rank {
        grid-row: span 2;
        font: 12px/1.9 var(--f-mono);
        color: var(--ink-3);
        text-align: right;
      }
      #list .nm {
        display: flex;
        align-items: center;
        gap: 6px;
        min-width: 0;
        font-weight: 600;
      }
      #list .nm span:last-child {
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
      }
      #list .score {
        font-family: var(--f-mono);
        color: var(--ink-2);
      }
      #list .meta {
        grid-column: 2 / 4;
        color: var(--ink-3);
        font-size: 12px;
      }
      #list .empty {
        padding: 14px 12px;
        color: var(--ink-3);
      }
      .linkbtn {
        padding: 0;
        border: 0;
        background: none;
        color: var(--blue);
        cursor: pointer;
      }
      .foot {
        padding: 10px 16px 18px;
        color: var(--ink-3);
        font-size: 11px;
      }

      /* ---------- the map ---------- */
      #map-wrap {
        position: relative;
        flex: 1;
        min-width: 0;
      }
      #map {
        position: absolute;
        inset: 0;
        /* shows while tiles load (or if they are blocked): a calm grid instead of flat grey */
        background:
          linear-gradient(var(--line) 1px, transparent 1px) 0 0 / 48px 48px,
          linear-gradient(90deg, var(--line) 1px, transparent 1px) 0 0 / 48px 48px,
          var(--map-bg);
        font: inherit;
      }
      .leaflet-tile-pane {
        filter: var(--tiles);
      }
      .pin {
        display: block;
        width: 16px;
        height: 16px;
        border: 2.5px solid var(--card);
        border-radius: 50%;
        background: var(--k);
        box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
        transition: scale 0.2s;
      }
      .pin.dim {
        background: var(--ink-3);
        opacity: 0.45;
        box-shadow: none;
      }
      .pin.hot {
        scale: 1.5;
        outline: 3px solid var(--hi);
      }
      .leaflet-marker-icon:focus-visible .pin {
        outline: 3px solid var(--blue);
      }
      .cl {
        display: grid;
        place-items: center;
        width: 36px;
        height: 36px;
        border: 2px solid var(--card);
        border-radius: 50%;
        background: var(--ink);
        color: var(--paper);
        font: 600 13px/1 var(--f-mono);
        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
        transition: scale 0.2s;
      }
      .cl.dim {
        background: var(--ink-3);
        opacity: 0.5;
      }
      .cl.big {
        width: 44px;
        height: 44px;
      }
      .cl.hot {
        scale: 1.2;
        outline: 3px solid var(--hi);
      }
      .start {
        display: grid;
        place-items: center;
        width: 26px;
        height: 26px;
        border: 2px solid var(--card);
        border-radius: 50%;
        background: var(--seal);
        color: #fff;
        font-size: 12px;
        font-weight: 700;
        box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
      }
      /* popups and controls follow the page theme */
      .leaflet-popup-content-wrapper,
      .leaflet-popup-tip {
        background: var(--card);
        color: var(--ink);
        box-shadow: 0 6px 24px rgba(0, 0, 0, 0.2);
      }
      .leaflet-popup-content-wrapper {
        border-radius: 12px;
      }
      .leaflet-popup-content {
        margin: 12px 14px;
        font: 13px/1.55 var(--f-sans);
      }
      .leaflet-container a.leaflet-popup-close-button {
        color: var(--ink-3);
      }
      .pop h3 {
        margin: 0 22px 2px 0;
        font-size: 15px;
      }
      .pop p {
        margin: 6px 0 0;
        color: var(--ink-2);
      }
      .pop .tags {
        display: flex;
        flex-wrap: wrap;
        gap: 4px;
        margin-top: 6px;
      }
      .tag {
        display: inline-flex;
        align-items: center;
        gap: 5px;
        padding: 0 6px;
        border-radius: 4px;
        background: var(--paper-2);
        color: var(--ink-2);
        font-size: 11px;
        white-space: nowrap;
      }
      .leaflet-bar a,
      .leaflet-container .leaflet-control-attribution {
        background: var(--card);
        color: var(--ink-2);
        border-color: var(--line);
      }
      .leaflet-control-attribution a {
        color: var(--blue);
      }
      .legend {
        position: absolute;
        right: 10px;
        bottom: 26px;
        z-index: 900;
        display: grid;
        gap: 2px;
        padding: 8px 10px;
        border: 1px solid var(--line);
        border-radius: 10px;
        background: color-mix(in srgb, var(--card) 90%, transparent);
        font-size: 12px;
        box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12);
      }
      .legend span {
        display: flex;
        align-items: center;
        gap: 6px;
      }
      .legend .n {
        display: grid;
        place-items: center;
        width: 14px;
        height: 14px;
        border-radius: 50%;
        background: var(--ink);
        color: var(--paper);
        font: 600 8px/1 var(--f-mono);
      }
      .floating {
        position: absolute;
        top: 10px;
        left: 56px;
        z-index: 900;
        display: none;
        padding: 6px 12px;
        border: 1px solid var(--line);
        border-radius: 999px;
        background: var(--card);
        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
        cursor: pointer;
      }

      /* ---------- narrow screens: the panel becomes a drawer over the map ---------- */
      @media (max-width: 760px) {
        #panel {
          position: absolute;
          inset: 0 auto 0 0;
          width: min(88vw, 340px);
          box-shadow: 4px 0 18px rgba(0, 0, 0, 0.18);
          transform: translateX(-104%);
          transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
        }
        #app.open #panel {
          transform: none;
        }
        .icon-btn,
        .floating {
          display: block;
        }
        .legend {
          bottom: 22px;
          right: 8px;
        }
      }
      @media (prefers-reduced-motion: reduce) {
        #panel,
        .pin,
        .cl,
        .chip {
          transition: none;
        }
      }
    </style>
  </head>
  <body>
    <div id="app">
      <aside id="panel" aria-label="筛选">
        <header class="head">
          <h1>湖边咖啡馆地图</h1>
          <p class="sub"><span class="stamp">示例数据</span><span id="stats"></span></p>
          <button id="panel-close" class="icon-btn" type="button" aria-label="收起筛选">×</button>
        </header>

        <div class="block">
          <input id="q" type="search" placeholder="搜店名…" autocomplete="off" aria-label="搜店名" />
        </div>

        <div class="block">
          <h2>类型</h2>
          <div id="kinds" class="chips"></div>
        </div>

        <div class="block">
          <label class="row" for="rating">评分下限 <span id="rating-val" class="val"></span></label>
          <input id="rating" type="range" min="3.5" max="4.8" step="0.1" value="4" />
          <label class="row"
            >营业到
            <select id="close">
              <option value="0">不限</option>
              <option value="20">20 点以后</option>
              <option value="22">22 点以后</option>
            </select>
          </label>
        </div>

        <div class="block">
          <h2>要有</h2>
          <div id="feats" class="checks"></div>
        </div>

        <div class="block">
          <label class="row"
            >排序
            <select id="sort">
              <option value="rating">评分最高</option>
              <option value="dist">离起点最近</option>
              <option value="name">按店名</option>
            </select>
          </label>
          <label class="row"
            >不符合的
            <select id="mode">
              <option value="dim">在地图上变灰</option>
              <option value="hide">从地图上隐藏</option>
            </select>
          </label>
          <p class="hint" id="start-hint">点地图任意位置设为起点，列表会显示距离。</p>
        </div>

        <div class="block results">
          <h2 id="count"></h2>
          <ol id="list"></ol>
        </div>

        <p class="foot">店名、位置、评分都是虚构的示例数据。底图 © OpenStreetMap contributors。</p>
      </aside>

      <main id="map-wrap">
        <div id="map"></div>
        <button id="panel-open" class="floating" type="button" aria-label="展开筛选">☰ 筛选</button>
        <div class="legend" id="legend" aria-hidden="true"></div>
      </main>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
    <script src="data.js"></script>
    <script>
      (function () {
        const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
        const KINDS = { hand: "手冲", bake: "烘焙", chain: "连锁", tea: "茶咖" };
        const FEATS = { socket: "有插座", wifi: "Wi-Fi", pet: "可带宠物", outdoor: "户外座位", quiet: "安静" };
        const $ = (s) => document.querySelector(s);
        const esc = (s) =>
          String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
        const cafes = window.CAFES || [];

        // ---------- filter state: the single source of truth for list + map ----------
        const state = {
          q: "",
          kinds: new Set(Object.keys(KINDS)),
          rating: 4,
          close: 0,
          feats: new Set(),
          sort: "rating",
          mode: "dim",
          start: null,
          sel: null,
        };

        const pass = (c) =>
          state.kinds.has(c.kind) &&
          c.rating >= state.rating - 1e-9 &&
          c.close >= state.close &&
          [...state.feats].every((f) => c.feats.includes(f)) &&
          (!state.q || c.name.includes(state.q));

        // ---------- map ----------
        const map = L.map("map", {
          zoomSnap: 0.5,
          zoomAnimation: !reduce,
          fadeAnimation: !reduce,
          markerZoomAnimation: !reduce,
        }).setView([30.265, 120.168], 14);
        L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
          maxZoom: 19,
          attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
        }).addTo(map);

        const cluster = L.markerClusterGroup({
          maxClusterRadius: 44,
          showCoverageOnHover: false,
          spiderfyOnMaxZoom: true,
          disableClusteringAtZoom: 17,
          animate: !reduce,
          // Cluster bubbles count only the cafés that pass the filters, so the numbers match the list.
          iconCreateFunction: (cl) => {
            const kids = cl.getAllChildMarkers();
            const n = kids.filter((m) => pass(m.cafe)).length;
            const big = kids.length >= 10 ? " big" : "";
            return L.divIcon({
              className: "",
              iconSize: null,
              html: `<span class="cl${big}${n ? "" : " dim"}">${n || "·"}</span>`,
            });
          },
        });
        map.addLayer(cluster);

        const pinIcon = (c, hot) =>
          L.divIcon({
            className: "",
            iconSize: [16, 16],
            iconAnchor: [8, 8],
            popupAnchor: [0, -8],
            html: `<span class="pin${pass(c) ? "" : " dim"}${hot ? " hot" : ""}" style="--k: var(--k-${c.kind})"></span>`,
          });

        const markers = new Map();
        for (const c of cafes) {
          const m = L.marker([c.lat, c.lng], { title: c.name, icon: pinIcon(c) });
          m.cafe = c;
          m.bindPopup(() => popupHtml(c), { maxWidth: 260, autoPanPadding: [24, 24] });
          m.on("popupopen", () => select(c.id, { fromMap: true }));
          m.on("popupclose", () => state.sel === c.id && select(null, { fromMap: true }));
          markers.set(c.id, m);
        }

        let startMarker = null;
        map.on("click", (e) => {
          state.start = e.latlng;
          if (!startMarker) {
            startMarker = L.marker(e.latlng, {
              icon: L.divIcon({ className: "", iconSize: [26, 26], html: '<span class="start">起</span>' }),
              keyboard: false,
              zIndexOffset: 1000,
            }).addTo(map);
          } else startMarker.setLatLng(e.latlng);
          $("#start-hint").innerHTML = '已设起点。<button class="linkbtn" id="clear-start" type="button">清除</button>';
          $("#sort").value = state.sort = "dist";
          render();
        });
        $("#start-hint").addEventListener("click", (e) => {
          if (e.target.id !== "clear-start") return;
          state.start = null;
          startMarker.remove();
          startMarker = null;
          $("#start-hint").textContent = "点地图任意位置设为起点，列表会显示距离。";
          if (state.sort === "dist") $("#sort").value = state.sort = "rating";
          render();
        });

        const distOf = (c) => (state.start ? map.distance(state.start, [c.lat, c.lng]) : null);
        const fmtDist = (d) =>
          d == null ? "" : d < 1000 ? Math.round(d / 10) * 10 + " m" : (d / 1000).toFixed(1) + " km";

        function popupHtml(c) {
          const d = distOf(c);
          return `<div class="pop">
            <h3>${esc(c.name)}</h3>
            <div class="tags"><span class="tag"><i class="sw" style="background: var(--k-${c.kind})"></i>${KINDS[c.kind]}</span>
            <span class="tag">★ ${c.rating.toFixed(1)}</span><span class="tag">营业到 ${c.close}:00</span><span class="tag">${c.seats} 座</span></div>
            <div class="tags">${c.feats.map((f) => `<span class="tag">${FEATS[f]}</span>`).join("")}</div>
            <p>${esc(c.note)}${d != null ? `<br />离起点 ${fmtDist(d)}` : ""}</p>
          </div>`;
        }

        // ---------- panel controls ----------
        $("#kinds").innerHTML = Object.entries(KINDS)
          .map(
            ([k, label]) =>
              `<button class="chip" type="button" data-k="${k}" aria-pressed="true"><i class="sw" style="background: var(--k-${k})"></i>${label}</button>`,
          )
          .join("");
        $("#kinds").addEventListener("click", (e) => {
          const b = e.target.closest("[data-k]");
          if (!b) return;
          const k = b.dataset.k;
          state.kinds.has(k) ? state.kinds.delete(k) : state.kinds.add(k);
          b.setAttribute("aria-pressed", String(state.kinds.has(k)));
          render();
        });
        $("#feats").innerHTML = Object.entries(FEATS)
          .map(([f, label]) => `<label class="check"><input type="checkbox" value="${f}" />${label}</label>`)
          .join("");
        $("#feats").addEventListener("change", (e) => {
          e.target.checked ? state.feats.add(e.target.value) : state.feats.delete(e.target.value);
          render();
        });
        $("#q").addEventListener("input", (e) => {
          state.q = e.target.value.trim();
          render();
        });
        $("#rating").addEventListener("input", (e) => {
          state.rating = +e.target.value;
          render();
        });
        $("#close").addEventListener("change", (e) => {
          state.close = +e.target.value;
          render();
        });
        $("#sort").addEventListener("change", (e) => {
          state.sort = e.target.value;
          if (state.sort === "dist" && !state.start)
            $("#start-hint").textContent = "先点地图设一个起点，才能按距离排。";
          render();
        });
        $("#mode").addEventListener("change", (e) => {
          state.mode = e.target.value;
          render();
        });
        $("#legend").innerHTML =
          Object.entries(KINDS)
            .map(([k, l]) => `<span><i class="sw" style="background: var(--k-${k})"></i>${l}</span>`)
            .join("") + '<span><i class="n">3</i>这一片符合的店数</span>';

        // ---------- render: list + markers from the same filtered set ----------
        function render() {
          const hits = cafes.filter(pass);
          const sorters = {
            rating: (a, b) => b.rating - a.rating,
            name: (a, b) => a.name.localeCompare(b.name, "zh-CN"),
            dist: (a, b) => (distOf(a) ?? 0) - (distOf(b) ?? 0) || b.rating - a.rating,
          };
          hits.sort(sorters[state.sort] || sorters.rating);

          $("#rating-val").textContent = state.rating <= 3.5 ? "不限" : "≥ " + state.rating.toFixed(1);
          $("#stats").textContent = `共 ${cafes.length} 家，符合 ${hits.length} 家`;
          $("#count").textContent = `结果 ${hits.length}`;
          $("#panel-open").textContent = `☰ 筛选 · ${hits.length}`;

          $("#list").innerHTML = hits.length
            ? hits
                .map((c, i) => {
                  const d = fmtDist(distOf(c));
                  const feats = c.feats.map((f) => FEATS[f]).join(" · ");
                  return `<li><button type="button" data-id="${c.id}" aria-current="${state.sel === c.id}">
                    <span class="rank">${i + 1}</span>
                    <span class="nm"><i class="sw" style="background: var(--k-${c.kind})"></i><span>${esc(c.name)}</span></span>
                    <span class="score">★ ${c.rating.toFixed(1)}</span>
                    <span class="meta">${KINDS[c.kind]} · 到 ${c.close}:00${feats ? " · " + feats : ""}${d ? " · " + d : ""}</span>
                  </button></li>`;
                })
                .join("")
            : '<li class="empty">没有符合的店，放宽一个条件试试。</li>';

          // Markers: either show everything with misses greyed out, or only the hits.
          const want = state.mode === "hide" ? hits.map((c) => markers.get(c.id)) : [...markers.values()];
          cluster.clearLayers();
          for (const m of want) m.setIcon(pinIcon(m.cafe, false));
          cluster.addLayers(want);
          if (state.sel != null && !want.includes(markers.get(state.sel))) select(null);
        }

        // ---------- selection + hover sync between list and map ----------
        function select(id, { fromMap = false } = {}) {
          state.sel = id;
          for (const b of document.querySelectorAll("#list button")) {
            const on = +b.dataset.id === id;
            b.setAttribute("aria-current", String(on));
            if (on && fromMap) b.scrollIntoView({ block: "nearest", behavior: reduce ? "auto" : "smooth" });
          }
          if (id == null || fromMap) return;
          const m = markers.get(id);
          if (!cluster.hasLayer(m)) return;
          // Zooms into the cluster that hides this marker (or spiderfies it), then opens its popup.
          cluster.zoomToShowLayer(m, () => m.openPopup());
          if (matchMedia("(max-width: 760px)").matches) $("#app").classList.remove("open");
        }
        function hover(id, on) {
          const m = markers.get(id);
          if (!m || !cluster.hasLayer(m)) return;
          const parent = cluster.getVisibleParent(m);
          if (parent === m) m.setIcon(pinIcon(m.cafe, on));
          else parent?.getElement()?.firstElementChild?.classList.toggle("hot", on);
        }
        $("#list").addEventListener("click", (e) => {
          const b = e.target.closest("[data-id]");
          if (b) select(+b.dataset.id);
        });
        $("#list").addEventListener("mouseover", (e) => {
          const b = e.target.closest("[data-id]");
          if (b) hover(+b.dataset.id, true);
        });
        $("#list").addEventListener("mouseout", (e) => {
          const b = e.target.closest("[data-id]");
          if (b) hover(+b.dataset.id, false);
        });

        // ---------- drawer on narrow screens ----------
        $("#panel-open").addEventListener("click", () => $("#app").classList.add("open"));
        $("#panel-close").addEventListener("click", () => $("#app").classList.remove("open"));

        render();
      })();
    </script>
  </body>
</html>
```

### data.js

```js
// Fictional sample data: names, positions, ratings and hours are all made up for the demo.
window.CAFES = [
  {
    id: 1,
    name: "风铃烘焙所",
    kind: "bake",
    lat: 30.27382,
    lng: 120.13891,
    rating: 4.1,
    close: 18,
    seats: 16,
    feats: ["quiet"],
    note: "上午十点出炉，可颂常常卖完",
  },
  {
    id: 2,
    name: "白鹭咖啡研究所",
    kind: "hand",
    lat: 30.26499,
    lng: 120.16417,
    rating: 3.6,
    close: 22,
    seats: 8,
    feats: ["wifi"],
    note: "豆单每周换，吧台可以聊产地",
  },
  {
    id: 3,
    name: "豆仓咖啡 · 门牌店",
    kind: "chain",
    lat: 30.27385,
    lng: 120.17126,
    rating: 4.0,
    close: 22,
    seats: 20,
    feats: ["socket", "wifi"],
    note: "出杯快，适合赶时间",
  },
  {
    id: 4,
    name: "慢岛咖啡",
    kind: "hand",
    lat: 30.26848,
    lng: 120.17218,
    rating: 3.8,
    close: 21,
    seats: 8,
    feats: ["socket"],
    note: "只做手冲和意式，菜单很短",
  },
  {
    id: 5,
    name: "柚子茶咖",
    kind: "tea",
    lat: 30.26243,
    lng: 120.17969,
    rating: 3.7,
    close: 22,
    seats: 40,
    feats: ["socket", "outdoor", "quiet"],
    note: "乌龙冷萃和手冲都有",
  },
  {
    id: 6,
    name: "半坡面包与咖啡",
    kind: "bake",
    lat: 30.2458,
    lng: 120.17847,
    rating: 4.0,
    close: 20,
    seats: 12,
    feats: ["socket", "wifi", "outdoor"],
    note: "上午十点出炉，可颂常常卖完",
  },
  {
    id: 7,
    name: "落叶烘焙所",
    kind: "bake",
    lat: 30.27104,
    lng: 120.19576,
    rating: 4.6,
    close: 19,
    seats: 40,
    feats: ["socket", "outdoor", "quiet"],
    note: "上午十点出炉，可颂常常卖完",
  },
  {
    id: 8,
    name: "纸飞机面包与咖啡",
    kind: "bake",
    lat: 30.27515,
    lng: 120.13921,
    rating: 4.0,
    close: 18,
    seats: 60,
    feats: ["socket"],
    note: "咖啡配司康，二楼有长桌",
  },
  {
    id: 9,
    name: "羽毛手冲",
    kind: "hand",
    lat: 30.26832,
    lng: 120.17072,
    rating: 4.4,
    close: 21,
    seats: 12,
    feats: ["socket", "outdoor", "quiet"],
    note: "老板自己烘豆，周末有杯测",
  },
  {
    id: 10,
    name: "山茶甜点咖啡",
    kind: "bake",
    lat: 30.24702,
    lng: 120.17495,
    rating: 4.2,
    close: 18,
    seats: 8,
    feats: ["socket", "pet", "outdoor"],
    note: "甜点比咖啡出名，适合约朋友",
  },
  {
    id: 11,
    name: "黑胶手冲",
    kind: "hand",
    lat: 30.24662,
    lng: 120.17772,
    rating: 3.7,
    close: 20,
    seats: 40,
    feats: ["socket", "wifi"],
    note: "豆单每周换，吧台可以聊产地",
  },
  {
    id: 12,
    name: "豆仓咖啡 · 青柠店",
    kind: "chain",
    lat: 30.25387,
    lng: 120.17325,
    rating: 4.4,
    close: 21,
    seats: 30,
    feats: ["socket", "wifi", "outdoor"],
    note: "座位多，网络稳定",
  },
  {
    id: 13,
    name: "秋千茶咖",
    kind: "tea",
    lat: 30.25524,
    lng: 120.19578,
    rating: 4.5,
    close: 22,
    seats: 40,
    feats: ["pet", "quiet"],
    note: "中式小院，有几张户外桌",
  },
  {
    id: 14,
    name: "北窗烘焙所",
    kind: "bake",
    lat: 30.28908,
    lng: 120.19549,
    rating: 3.9,
    close: 19,
    seats: 40,
    feats: ["socket", "wifi", "outdoor"],
    note: "上午十点出炉，可颂常常卖完",
  },
  {
    id: 15,
    name: "豆仓咖啡 · 砂糖店",
    kind: "chain",
    lat: 30.2807,
    lng: 120.13987,
    rating: 3.8,
    close: 23,
    seats: 60,
    feats: ["socket", "wifi", "pet", "quiet"],
    note: "出杯快，适合赶时间",
  },
  {
    id: 16,
    name: "豆仓咖啡 · 海盐店",
    kind: "chain",
    lat: 30.26775,
    lng: 120.18844,
    rating: 3.9,
    close: 22,
    seats: 8,
    feats: ["socket", "wifi", "pet", "quiet"],
    note: "座位多，网络稳定",
  },
  {
    id: 17,
    name: "豆仓咖啡 · 巷尾店",
    kind: "chain",
    lat: 30.25198,
    lng: 120.16756,
    rating: 4.5,
    close: 18,
    seats: 40,
    feats: ["socket", "wifi", "outdoor"],
    note: "座位多，网络稳定",
  },
  {
    id: 18,
    name: "豆仓咖啡 · 松果店",
    kind: "chain",
    lat: 30.24761,
    lng: 120.19179,
    rating: 4.0,
    close: 23,
    seats: 12,
    feats: ["socket", "wifi"],
    note: "出杯快，适合赶时间",
  },
  {
    id: 19,
    name: "长椅茶与咖啡",
    kind: "tea",
    lat: 30.25754,
    lng: 120.17653,
    rating: 4.7,
    close: 23,
    seats: 20,
    feats: ["wifi"],
    note: "茶底拿铁是招牌",
  },
  {
    id: 20,
    name: "铁皮咖啡",
    kind: "hand",
    lat: 30.25072,
    lng: 120.1972,
    rating: 3.7,
    close: 18,
    seats: 24,
    feats: ["socket", "wifi", "outdoor", "quiet"],
    note: "豆单每周换，吧台可以聊产地",
  },
  {
    id: 21,
    name: "橘子茶与咖啡",
    kind: "tea",
    lat: 30.24385,
    lng: 120.19222,
    rating: 4.1,
    close: 23,
    seats: 16,
    feats: ["wifi"],
    note: "中式小院，有几张户外桌",
  },
  {
    id: 22,
    name: "苔藓烘焙所",
    kind: "bake",
    lat: 30.26846,
    lng: 120.16972,
    rating: 4.4,
    close: 20,
    seats: 12,
    feats: ["wifi", "outdoor", "quiet"],
    note: "上午十点出炉，可颂常常卖完",
  },
  {
    id: 23,
    name: "角落咖啡研究所",
    kind: "hand",
    lat: 30.24842,
    lng: 120.18119,
    rating: 3.7,
    close: 23,
    seats: 20,
    feats: ["socket", "pet"],
    note: "老板自己烘豆，周末有杯测",
  },
  {
    id: 24,
    name: "拾光面包与咖啡",
    kind: "bake",
    lat: 30.28615,
    lng: 120.19298,
    rating: 4.1,
    close: 20,
    seats: 24,
    feats: ["socket"],
    note: "甜点比咖啡出名，适合约朋友",
  },
  {
    id: 25,
    name: "云朵茶咖",
    kind: "tea",
    lat: 30.26824,
    lng: 120.18858,
    rating: 4.8,
    close: 22,
    seats: 30,
    feats: ["wifi", "outdoor", "quiet"],
    note: "中式小院，有几张户外桌",
  },
  {
    id: 26,
    name: "豆仓咖啡 · 木马店",
    kind: "chain",
    lat: 30.26099,
    lng: 120.17278,
    rating: 3.9,
    close: 22,
    seats: 12,
    feats: ["socket", "wifi", "outdoor"],
    note: "座位多，网络稳定",
  },
  {
    id: 27,
    name: "小满茶咖",
    kind: "tea",
    lat: 30.28082,
    lng: 120.14584,
    rating: 4.8,
    close: 21,
    seats: 60,
    feats: ["socket", "wifi"],
    note: "乌龙冷萃和手冲都有",
  },
  {
    id: 28,
    name: "晴天茶咖",
    kind: "tea",
    lat: 30.26466,
    lng: 120.18757,
    rating: 3.9,
    close: 21,
    seats: 60,
    feats: ["socket", "wifi", "pet"],
    note: "乌龙冷萃和手冲都有",
  },
  {
    id: 29,
    name: "书页烘焙所",
    kind: "bake",
    lat: 30.28353,
    lng: 120.15517,
    rating: 4.4,
    close: 19,
    seats: 12,
    feats: ["wifi"],
    note: "甜点比咖啡出名，适合约朋友",
  },
  {
    id: 30,
    name: "豆仓咖啡 · 蜂巢店",
    kind: "chain",
    lat: 30.27268,
    lng: 120.18405,
    rating: 4.0,
    close: 24,
    seats: 20,
    feats: ["socket", "wifi", "pet", "quiet"],
    note: "营业时间长，深夜也开",
  },
  {
    id: 31,
    name: "豆仓咖啡 · 栗子店",
    kind: "chain",
    lat: 30.25138,
    lng: 120.19132,
    rating: 4.1,
    close: 19,
    seats: 40,
    feats: ["socket", "wifi"],
    note: "座位多，网络稳定",
  },
  {
    id: 32,
    name: "豆仓咖啡 · 石板店",
    kind: "chain",
    lat: 30.24049,
    lng: 120.1698,
    rating: 4.1,
    close: 19,
    seats: 8,
    feats: ["socket", "wifi", "pet"],
    note: "出杯快，适合赶时间",
  },
  {
    id: 33,
    name: "旧钟茶咖",
    kind: "tea",
    lat: 30.26084,
    lng: 120.1642,
    rating: 3.9,
    close: 23,
    seats: 8,
    feats: ["socket", "wifi"],
    note: "中式小院，有几张户外桌",
  },
  {
    id: 34,
    name: "杏仁茶咖",
    kind: "tea",
    lat: 30.25486,
    lng: 120.19778,
    rating: 4.1,
    close: 23,
    seats: 24,
    feats: ["wifi", "outdoor", "quiet"],
    note: "中式小院，有几张户外桌",
  },
  {
    id: 35,
    name: "白瓷烘焙所",
    kind: "bake",
    lat: 30.26756,
    lng: 120.15717,
    rating: 4.7,
    close: 18,
    seats: 60,
    feats: ["outdoor", "quiet"],
    note: "咖啡配司康，二楼有长桌",
  },
  {
    id: 36,
    name: "月台手冲",
    kind: "hand",
    lat: 30.2653,
    lng: 120.19768,
    rating: 4.0,
    close: 24,
    seats: 60,
    feats: ["socket", "wifi", "outdoor"],
    note: "豆单每周换，吧台可以聊产地",
  },
  {
    id: 37,
    name: "麦穗茶咖",
    kind: "tea",
    lat: 30.24036,
    lng: 120.17923,
    rating: 4.4,
    close: 18,
    seats: 12,
    feats: ["socket"],
    note: "茶底拿铁是招牌",
  },
  {
    id: 38,
    name: "雾里茶咖",
    kind: "tea",
    lat: 30.26368,
    lng: 120.17995,
    rating: 3.7,
    close: 21,
    seats: 30,
    feats: ["socket", "wifi", "outdoor", "quiet"],
    note: "中式小院，有几张户外桌",
  },
  {
    id: 39,
    name: "邮筒手冲",
    kind: "hand",
    lat: 30.27016,
    lng: 120.15264,
    rating: 4.9,
    close: 22,
    seats: 20,
    feats: ["wifi", "outdoor"],
    note: "老板自己烘豆，周末有杯测",
  },
  {
    id: 40,
    name: "萤火咖啡",
    kind: "hand",
    lat: 30.28831,
    lng: 120.1805,
    rating: 4.1,
    close: 22,
    seats: 30,
    feats: ["pet", "outdoor"],
    note: "老板自己烘豆，周末有杯测",
  },
  {
    id: 41,
    name: "豆仓咖啡 · 回声店",
    kind: "chain",
    lat: 30.26462,
    lng: 120.18966,
    rating: 4.2,
    close: 21,
    seats: 8,
    feats: ["socket", "wifi"],
    note: "座位多，网络稳定",
  },
  {
    id: 42,
    name: "灯塔咖啡研究所",
    kind: "hand",
    lat: 30.28908,
    lng: 120.19499,
    rating: 4.9,
    close: 19,
    seats: 8,
    feats: ["wifi", "outdoor", "quiet"],
    note: "只做手冲和意式，菜单很短",
  },
  {
    id: 43,
    name: "豆仓咖啡 · 鲸鱼店",
    kind: "chain",
    lat: 30.25009,
    lng: 120.18972,
    rating: 3.9,
    close: 22,
    seats: 60,
    feats: ["socket", "wifi"],
    note: "出杯快，适合赶时间",
  },
  {
    id: 44,
    name: "码头手冲",
    kind: "hand",
    lat: 30.28571,
    lng: 120.18407,
    rating: 3.7,
    close: 20,
    seats: 12,
    feats: ["wifi", "outdoor"],
    note: "老板自己烘豆，周末有杯测",
  },
];
```
