BLK-51Blocks
Service Coverage Area (Simplified Map + Area List)
A service-area block that answers "is my address covered?" rather than "where is your office?". Instead of a real map it draws a hand-built SVG district diagram from a jittered shared lattice, shading four coverage tiers in amber, with a municipality ledger (reading, travel fee, earliest visit, conditions) beside it. A single search box filters diagram and list together, hover/focus cross-highlights both, and the shading spreads outward from the base location. Zero images, zero dependencies.
- Added:
- 2026-09-04
- Dependencies:
- None
- tags
- #block #coverage-area #service-area #map #svg #cartogram #search #highlight #hairline #a11y #no-image #no-deps
Preview
対応エリア
ご住所の市区町村から、伺えるかどうかを確かめる
対応の可否は距離ではなく訪問の段取りで決まります。市区町村ごとに、当日伺えるか・出張費がかかるかをそのまま公開しています。地名を入力すると、図と一覧の両方が絞り込まれます。
- 当日対応
- 翌営業日
- 要相談
- 対象外
- 拠点
- 出張費
- 片道の実費を上限とし、金額はお見積りに明記します
- 当日対応
- 12:00 までにご連絡いただいた分が対象です
- 範囲外
- 訪問は承っていませんが、画面共有での打ち合わせに対応します
全12件中 12件を表示
掲載しているのは訪問の可否と条件です。一覧にない地域のご相談も受け付けていますので、実際の日程とあわせてお問い合わせください。
対応の可否を問い合わせる"use client";
import { useEffect, useId, useRef, useState } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({
weight: ["600", "700"],
subsets: ["latin"],
display: "swap",
});
/**
* 対応エリア(簡略マップ × 市区町村の可否台帳)
* 既存の会社概要系は「自社がどこにあるか」を示す構造(BLK-19=1拠点への道順・BLK-50=N拠点の横断台帳)が
* 既出のため、本ブロックは向きを反転させ「相手の住所が範囲に入るか」を答える構造にした。
* 主操作はタブでも絞り込みでもなく地名の検索で、地図は塗り分けの凡例として従属する。
* 飽和していた角丸・末尾の矢印リンク・英字キッカーは意図的に使っていない(見出しは和文ラベルのみ)。
*
* 汎用技法メモ(web-design-playbook 還流用):
* - 格子ジッタ・カルトグラム: 実地図を使わず区割り図を作る型。(COLS+1)×(ROWS+1) の格子点を
* 1度だけ決定論的にジッタさせ、各区画はその4点から組む。点を共有しているので隙間も重なりも
* 構造的に発生しない。各頂点を重心方向へ 2px 寄せると区画間に白い縫い目ができ、罫線を1本も
* 引かずに「区割り」が読める。ジッタは Math.sin ベースの決定論的擬似乱数を**モジュール直下**で
* 評価する(乱数を使うと SSR とクライアントで座標が変わり hydration が割れる)。
* - 出現順は配列の index ではなく「基点からの距離」でソートして遅延を配る。塗りが拠点から
* 外側へ広がるので、同じスタッガーでも意味(=対応範囲が伸びていく)を持つ。
* - SVG 要素に scale を効かせるには transform-box: fill-box と transform-origin: center が要る。
* 付けないと viewBox 原点基準で拡大され、区画が画面外へ飛ぶ。
* - 図とリストの相互ハイライトは hover/focus(一時)と click(固定)を別の state に分け、
* 見た目は hovered ?? pinned で決める。1つに束ねると focus しただけで aria-pressed が true になり、
* 「押していないボタンが押された状態」を読み上げてしまう。SVG 側は aria-hidden にして
* 正の情報を必ずリスト側へ置く。検索の絞り込みも同じ query 1つで図とリストの両方を制御する。
* - <pattern> の id は useId() で作る。同一ページに同じブロックを2つ置くと id が衝突し、
* 後勝ちで片方のハッチが消える。
*/
type Tier = "same" | "next" | "consult" | "out";
const tiers: Record<
Tier,
{ label: string; fee: string; lead: string; swatch: string; text: string }
> = {
same: {
label: "当日対応",
fee: "無料",
lead: "最短 当日",
swatch: "bg-amber-600",
text: "text-amber-800",
},
next: {
label: "翌営業日",
fee: "無料",
lead: "最短 翌営業日",
swatch: "bg-amber-500/30",
text: "text-amber-700",
},
consult: {
label: "要相談",
fee: "3,000円〜",
lead: "最短 3営業日",
swatch: "bg-white",
text: "text-gray-700",
},
out: {
label: "対象外",
fee: "—",
lead: "オンラインで対応",
swatch: "bg-gray-100",
text: "text-gray-400",
},
};
type Area = {
id: string;
name: string;
/** 検索用の読み。漢字が出てこなくても引けるようにする */
yomi: string;
tier: Tier;
/** 格子上の区画位置(列, 行) */
col: number;
row: number;
/** 拠点を置く区画 */
base?: boolean;
/** 条件が付く区画にだけ添える1行 */
note?: string;
};
const areas: Area[] = [
{ id: "keyaki", name: "けやき市", yomi: "けやきし", tier: "same", col: 0, row: 0, base: true },
{ id: "minato", name: "みなと区", yomi: "みなとく", tier: "same", col: 1, row: 0 },
{ id: "sakuradai", name: "さくら台市", yomi: "さくらだいし", tier: "next", col: 2, row: 0 },
{
id: "shirasagi",
name: "白鷺町",
yomi: "しらさぎまち",
tier: "consult",
col: 3,
row: 0,
note: "月・木のみ巡回。他曜日は出張費を申し受けます",
},
{ id: "nagisa", name: "なぎさ市", yomi: "なぎさし", tier: "same", col: 0, row: 1 },
{ id: "nakazato", name: "中里区", yomi: "なかざとく", tier: "same", col: 1, row: 1 },
{ id: "hibari", name: "ひばりが丘市", yomi: "ひばりがおかし", tier: "next", col: 2, row: 1 },
{ id: "wakaba", name: "若葉町", yomi: "わかばまち", tier: "next", col: 3, row: 1 },
{ id: "uminobe", name: "うみのべ町", yomi: "うみのべまち", tier: "next", col: 0, row: 2 },
{
id: "minamino",
name: "南野市",
yomi: "みなみのし",
tier: "consult",
col: 1,
row: 2,
note: "橋の通行止め期間は翌々営業日以降になります",
},
{
id: "okumine",
name: "奥峰村",
yomi: "おくみねむら",
tier: "out",
col: 2,
row: 2,
note: "訪問は承っていません。画面共有での対応は可能です",
},
{
id: "misakino",
name: "岬野村",
yomi: "みさきのむら",
tier: "out",
col: 3,
row: 2,
note: "訪問は承っていません。画面共有での対応は可能です",
},
];
/** 全エリアに共通する条件。台帳の各行に同じ値を12回並べず、図の側へ1度だけ置く */
const conditions: { term: string; value: string }[] = [
{ term: "出張費", value: "片道の実費を上限とし、金額はお見積りに明記します" },
{ term: "当日対応", value: "12:00 までにご連絡いただいた分が対象です" },
{ term: "範囲外", value: "訪問は承っていませんが、画面共有での打ち合わせに対応します" },
];
/* ------------------------------------------------ 格子ジッタ・カルトグラム */
const COLS = 4;
const ROWS = 3;
const VIEW_W = 440;
const VIEW_H = 340;
/** 決定論的な擬似乱数(0..1)。SSR とクライアントで同じ座標を出すため Math.random は使わない */
function noise(col: number, row: number, salt: number) {
const n = Math.sin(col * 127.1 + row * 311.7 + salt * 74.7) * 43758.5453;
return n - Math.floor(n);
}
/** 格子点(列 0..COLS・行 0..ROWS)。共有点をジッタさせるので区画に隙間ができない */
const lattice: { x: number; y: number }[][] = Array.from(
{ length: COLS + 1 },
(_, c) =>
Array.from({ length: ROWS + 1 }, (_, r) => ({
x: 16 + c * 102 + (noise(c, r, 1) - 0.5) * 22,
y: 14 + r * 104 + (noise(c, r, 2) - 0.5) * 18,
})),
);
/** 各頂点を重心方向へ寄せる。区画の間に白い縫い目ができ、罫線なしで区割りが読める */
function shrink(pts: { x: number; y: number }[], cx: number, cy: number, d = 2.4) {
return pts.map((p) => {
const dx = cx - p.x;
const dy = cy - p.y;
const len = Math.hypot(dx, dy) || 1;
return { x: p.x + (dx / len) * d, y: p.y + (dy / len) * d };
});
}
type Zone = { id: string; points: string; cx: number; cy: number };
const zones: Record<string, Zone> = {};
for (const a of areas) {
const corners = [
lattice[a.col][a.row],
lattice[a.col + 1][a.row],
lattice[a.col + 1][a.row + 1],
lattice[a.col][a.row + 1],
];
const cx = corners.reduce((s, p) => s + p.x, 0) / 4;
const cy = corners.reduce((s, p) => s + p.y, 0) / 4;
zones[a.id] = {
id: a.id,
cx,
cy,
points: shrink(corners, cx, cy)
.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`)
.join(" "),
};
}
/** 出現順は index ではなく拠点からの距離。塗りが拠点から外へ広がる */
const baseZone = zones[areas.find((a) => a.base)?.id ?? areas[0].id];
const revealOrder = new Map(
[...areas]
.sort((a, b) => {
const da = Math.hypot(zones[a.id].cx - baseZone.cx, zones[a.id].cy - baseZone.cy);
const db = Math.hypot(zones[b.id].cx - baseZone.cx, zones[b.id].cy - baseZone.cy);
return da - db;
})
.map((a, i) => [a.id, i] as const),
);
/** 区画の塗り。要相談は塗らずハッチで「条件付き」を示す */
function zoneFill(tier: Tier, hatchId: string) {
if (tier === "same") return { className: "fill-amber-600", style: undefined };
if (tier === "next") return { className: "fill-amber-500/30", style: undefined };
if (tier === "out") return { className: "fill-gray-100", style: undefined };
return { className: "fill-white", style: { fill: `url(#${hatchId})` } };
}
/* ------------------------------------------------------------------ 本体 */
export default function AreaCoverageMap() {
const rootRef = useRef<HTMLElement>(null);
const uid = useId().replace(/:/g, "");
const hatchId = `acv-hatch-${uid}`;
const [shown, setShown] = useState(false);
// hovered=一時(hover/focus)・pinned=固定(クリック)。見た目は hovered を優先する
const [hovered, setHovered] = useState<string | null>(null);
const [pinned, setPinned] = useState<string | null>(null);
const activeId = hovered ?? pinned;
const [query, setQuery] = useState("");
// 画面内に入ったら塗りを広げる(IO 非対応環境は即時表示)
useEffect(() => {
const el = rootRef.current;
if (!el) return;
if (typeof IntersectionObserver === "undefined") {
const id = requestAnimationFrame(() => setShown(true));
return () => cancelAnimationFrame(id);
}
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
setShown(true);
io.disconnect();
}
},
// 縦に長い節では threshold(面積比)が満たされず塗りが出ないまま残る。
// 節の上端がビューポートに入ったら発火させる rootMargin 方式にする。
{ threshold: 0, rootMargin: "0px 0px -15% 0px" },
);
io.observe(el);
return () => io.disconnect();
}, []);
const q = query.trim().toLowerCase();
const hit = (a: Area) =>
q === "" ||
a.name.toLowerCase().includes(q) ||
a.yomi.includes(q) ||
tiers[a.tier].label.includes(q);
const filtered = areas.filter(hit);
return (
<section
ref={rootRef}
aria-labelledby="area-coverage-map-heading"
className="@container w-full max-w-5xl bg-white"
>
{/* 見出し(英字キッカーは置かない。和文ラベル+短いアンバー罫線で節を開く) */}
<header className="max-w-2xl">
<p className="flex items-center gap-2.5 text-[12px] font-bold tracking-widest text-gray-500">
<span aria-hidden="true" className="h-px w-6 flex-none bg-amber-600" />
対応エリア
</p>
<h2
id="area-coverage-map-heading"
className="mt-4 text-[clamp(1.55rem,3.6vw,2.2rem)] leading-[1.35] font-bold tracking-tight text-gray-900"
>
ご住所の市区町村から、
<span className="whitespace-nowrap">伺えるかどうかを確かめる</span>
</h2>
<p className="mt-5 text-[15px] leading-[1.9] font-medium text-gray-600">
対応の可否は距離ではなく訪問の段取りで決まります。市区町村ごとに、当日伺えるか・出張費がかかるかをそのまま公開しています。地名を入力すると、図と一覧の両方が絞り込まれます。
</p>
</header>
<div className="mt-9 grid grid-cols-1 gap-9 border-t border-gray-200 pt-9 @2xl:grid-cols-[minmax(0,1fr)_minmax(0,19rem)] @2xl:gap-10">
{/* 左: 簡略マップ(装飾。情報の正は右の一覧) */}
{/* sticky にはしない。一覧カードは transform: scale した中で描かれるため、
sticky の基準がスケール後の座標になり図だけが下へずれて見える */}
<figure className="m-0 min-w-0">
<svg
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
aria-hidden="true"
className="block h-auto w-full border border-gray-200 bg-[#fbfaf8]"
>
<defs>
{/* 要相談=塗らずにハッチ。面積で強さを誤読させない */}
<pattern
id={hatchId}
width="8"
height="8"
patternUnits="userSpaceOnUse"
patternTransform="rotate(45)"
>
<line
x1="0"
y1="0"
x2="0"
y2="8"
className="stroke-amber-500/60"
strokeWidth="1.8"
/>
</pattern>
</defs>
{areas.map((a) => {
const z = zones[a.id];
const on = activeId === a.id;
const dim = q !== "" && !hit(a);
const fill = zoneFill(a.tier, hatchId);
const label =
a.tier === "same" ? "fill-white" : a.tier === "out" ? "fill-gray-500" : "fill-gray-700";
return (
<g
key={a.id}
onMouseEnter={() => setHovered(a.id)}
onMouseLeave={() => setHovered((cur) => (cur === a.id ? null : cur))}
className={`transition-[opacity,scale] duration-500 ease-out motion-reduce:transition-none ${
on ? "scale-[1.035]" : "scale-100"
} ${
shown
? dim
? "opacity-25"
: "opacity-100"
: "opacity-0 motion-reduce:opacity-100"
}`}
style={{
transformBox: "fill-box",
transformOrigin: "center",
transitionDelay: shown && q === "" ? `${(revealOrder.get(a.id) ?? 0) * 55}ms` : "0ms",
}}
>
<polygon
points={z.points}
className={`${fill.className} transition-[stroke] duration-200 motion-reduce:transition-none ${
on ? "stroke-amber-700" : "stroke-transparent"
}`}
style={fill.style}
strokeWidth={on ? 2.5 : 1}
/>
{/* 図は幅に合わせて縮むので、狭い容器では地名を出さない(読めない文字を敷かない)。
正の情報は右の一覧が持つので、狭いときの図は塗り分けの要約として機能する。 */}
<text
x={z.cx}
y={a.base ? z.cy + 10 : z.cy + 4}
textAnchor="middle"
className={`hidden text-[11px] font-bold @lg:block ${label}`}
>
{a.name}
</text>
{a.base && (
<>
<rect
x={z.cx - 4.5}
y={z.cy - 15}
width="9"
height="9"
className="fill-white stroke-amber-700"
strokeWidth="2"
/>
<text
x={z.cx}
y={z.cy - 20}
textAnchor="middle"
className="hidden fill-white text-[9px] font-bold @lg:block"
>
拠点
</text>
</>
)}
</g>
);
})}
</svg>
{/* 凡例 */}
<ul className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2">
{(Object.keys(tiers) as Tier[]).map((t) => (
<li key={t} className="flex items-center gap-2 text-[12px] font-medium text-gray-600">
<span
aria-hidden="true"
className={`size-3 flex-none border ${
t === "consult" ? "border-amber-500" : "border-gray-300"
} ${tiers[t].swatch}`}
style={
t === "consult"
? {
backgroundImage:
"repeating-linear-gradient(45deg, rgba(217,119,6,0.55) 0 1.5px, transparent 1.5px 5px)",
}
: undefined
}
/>
{tiers[t].label}
</li>
))}
<li className="flex items-center gap-2 text-[12px] font-medium text-gray-600">
<span
aria-hidden="true"
className="size-3 flex-none border-2 border-amber-700 bg-white"
/>
拠点
</li>
</ul>
<figcaption className="mt-4 border-t border-gray-100 pt-3 text-[11.5px] leading-[1.9] text-gray-500">
<span className={`${montserrat.className} font-semibold text-gray-400`}>BLK-51</span>
{" / 対応エリア図(区割りを示す簡略図で、実際の地形・面積とは一致しません)"}
</figcaption>
{/* 表に入れると12行すべてに同じ値が並ぶ条件は、図の側に1度だけ書く */}
<dl className="mt-6 divide-y divide-gray-100 border-t border-gray-200">
{conditions.map((row) => (
<div key={row.term} className="grid grid-cols-[4.5rem_1fr] gap-x-4 py-3">
<dt className="pt-[0.15em] text-[11.5px] font-bold tracking-widest whitespace-nowrap text-gray-500">
{row.term}
</dt>
<dd className="text-[12.5px] leading-[1.85] text-gray-700">{row.value}</dd>
</div>
))}
</dl>
</figure>
{/* 右: 地名で引く一覧(ここが情報の正) */}
<div className="min-w-0">
<label
htmlFor="area-coverage-query"
className="block text-[12px] font-bold tracking-widest text-gray-500"
>
市区町村名で調べる
</label>
<div className="mt-2.5 flex items-center gap-2 border border-gray-300 px-3 py-2 transition-colors duration-200 focus-within:border-amber-600 motion-reduce:transition-none">
<svg
aria-hidden="true"
viewBox="0 0 20 20"
className="size-4 flex-none stroke-gray-400"
fill="none"
strokeWidth="1.8"
>
<circle cx="9" cy="9" r="5.5" />
<line x1="13.2" y1="13.2" x2="17.5" y2="17.5" strokeLinecap="round" />
</svg>
<input
id="area-coverage-query"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="例: けやき / ひばり / 当日"
className="w-full min-w-0 bg-transparent py-0.5 text-[14px] text-gray-900 placeholder:text-gray-400 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
/>
{query !== "" && (
<button
type="button"
onClick={() => setQuery("")}
className="flex-none border border-gray-300 px-2 py-0.5 text-[11px] font-bold text-gray-600 transition-colors duration-200 hover:border-gray-500 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
>
消す
</button>
)}
</div>
<p
role="status"
className="mt-3 text-[12px] leading-[1.8] font-medium text-gray-500 tabular-nums"
>
{`全${areas.length}件中 ${filtered.length}件を表示`}
</p>
<ul className="mt-3 divide-y divide-gray-100 border-t border-b border-gray-200">
{filtered.map((a) => {
const on = activeId === a.id;
const tier = tiers[a.tier];
return (
<li key={a.id}>
<button
type="button"
aria-pressed={pinned === a.id}
onClick={() => setPinned((cur) => (cur === a.id ? null : a.id))}
onMouseEnter={() => setHovered(a.id)}
onMouseLeave={() => setHovered((cur) => (cur === a.id ? null : cur))}
onFocus={() => setHovered(a.id)}
onBlur={() => setHovered((cur) => (cur === a.id ? null : cur))}
className="group relative block w-full py-3.5 pl-3 text-left transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
>
{/* 左の細い縦罫が選択の手がかり。面を塗らずに強調する */}
<span
aria-hidden="true"
className={`absolute top-2.5 bottom-2.5 left-0 w-[3px] origin-top bg-amber-600 transition-transform duration-300 ease-out motion-reduce:transition-none ${
on ? "scale-y-100" : "scale-y-0"
}`}
/>
<span className="sr-only">地図で位置を示す:</span>
<span className="flex items-baseline justify-between gap-3">
<span className="min-w-0">
<span className="block text-[14.5px] leading-[1.5] font-bold text-gray-900">
{a.name}
</span>
<span className="mt-0.5 block text-[11px] leading-[1.6] text-gray-400">
{a.yomi}
</span>
</span>
<span
className={`flex flex-none items-center gap-1.5 text-[12px] font-bold ${tier.text}`}
>
<span
aria-hidden="true"
className={`size-2.5 flex-none border ${
a.tier === "consult" ? "border-amber-500" : "border-gray-300"
} ${tier.swatch}`}
style={
a.tier === "consult"
? {
backgroundImage:
"repeating-linear-gradient(45deg, rgba(217,119,6,0.55) 0 1.5px, transparent 1.5px 5px)",
}
: undefined
}
/>
{tier.label}
</span>
</span>
<span className="mt-2 flex flex-wrap items-baseline gap-x-4 gap-y-1 text-[11.5px] leading-[1.7] text-gray-600">
<span className="flex items-baseline gap-1.5">
<span className="text-gray-400">出張費</span>
<span className={`${montserrat.className} font-semibold tabular-nums`}>
{tier.fee}
</span>
</span>
<span className="flex items-baseline gap-1.5">
<span className="text-gray-400">訪問</span>
<span className="font-semibold text-gray-800">{tier.lead}</span>
</span>
</span>
{a.note && (
<span className="mt-1.5 flex items-start gap-2 text-[11.5px] leading-[1.85] text-gray-500">
<span aria-hidden="true" className="mt-[0.8em] h-px w-2.5 flex-none bg-amber-600" />
<span>{a.note}</span>
</span>
)}
</button>
</li>
);
})}
</ul>
{filtered.length === 0 && (
<p className="mt-4 text-[13px] leading-[1.9] text-gray-600">
一致する市区町村がありません。近隣の地名でもお探しいただけますが、一覧にない地域はご相談として承ります。
</p>
)}
</div>
</div>
{/* 範囲外の逃げ道は必ずテキストで残す */}
<div className="mt-9 flex flex-col gap-4 border-t border-gray-200 pt-6 @2xl:flex-row @2xl:items-baseline @2xl:justify-between">
<p className="max-w-xl text-[13px] leading-[1.9] text-gray-600">
掲載しているのは訪問の可否と条件です。一覧にない地域のご相談も受け付けていますので、実際の日程とあわせてお問い合わせください。
</p>
<a
href="#"
className="group inline-flex flex-none items-center text-[13px] font-semibold text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
>
<span className="relative">
対応の可否を問い合わせる
<span
aria-hidden="true"
className="absolute -bottom-1 left-0 h-px w-full origin-left scale-x-100 bg-gray-900 transition-transform duration-300 ease-out group-hover:scale-x-0 motion-reduce:transition-none"
/>
<span
aria-hidden="true"
className="absolute -bottom-1 left-0 h-px w-full origin-right scale-x-0 bg-amber-600 transition-transform delay-150 duration-300 ease-out group-hover:origin-left group-hover:scale-x-100 motion-reduce:transition-none"
/>
</span>
</a>
</div>
</section>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/area-coverage-map.json