From 98397bc9429348dd33a350d148ea9044aa3dca1e Mon Sep 17 00:00:00 2001 From: ochanuco Date: Thu, 19 Mar 2026 23:55:20 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E8=A3=9C=E7=B5=A6=E5=9C=B0?= =?UTF-8?q?=E7=82=B9=E3=83=9E=E3=83=83=E3=83=97MVP=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + README.md | 28 +++ frontend/app.js | 338 ++++++++++++++++++++++++++++++++++ frontend/gpx.js | 44 +++++ frontend/index.html | 86 +++++++++ frontend/route_math.js | 108 +++++++++++ frontend/styles.css | 255 ++++++++++++++++++++++++++ lib/map_data.js | 368 ++++++++++++++++++++++++++++++++++++++ package.json | 4 +- scripts/export_map_db.js | 109 +++++++++++ scripts/map_dev_server.js | 139 ++++++++++++++ tests/map_data.test.js | 163 +++++++++++++++++ tests/route_math.test.js | 62 +++++++ 13 files changed, 1704 insertions(+), 1 deletion(-) create mode 100644 frontend/app.js create mode 100644 frontend/gpx.js create mode 100644 frontend/index.html create mode 100644 frontend/route_math.js create mode 100644 frontend/styles.css create mode 100644 lib/map_data.js create mode 100644 scripts/export_map_db.js create mode 100644 scripts/map_dev_server.js create mode 100644 tests/map_data.test.js create mode 100644 tests/route_math.test.js diff --git a/.gitignore b/.gitignore index 6ea4039..82ed2bb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ dataform/.df-credentials.json playwright-report/ test-results/ data/ +.local/ diff --git a/README.md b/README.md index 9f2f966..83cde4a 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ - `apps/`: Cloud Run ジョブ/サービス - `dataform/`: Dataform 定義 +- `frontend/`: 補給地点マップの静的 UI - `terraform/`: IaC - `skills/`: 本リポジトリ用の作業スキル @@ -130,6 +131,33 @@ npm run bq:upsert:geocoded -- \ - 取得元データに存在しない場合、`detail_url` は出力しません - `bq:upsert:geocoded` は `schemas/raw/stores_geocoded.json` を使って一時テーブルへロードし、`chain + store_id` 単位で最新 `geocoded_at` を残す Upsert を行います +## 補給地点マップ(ローカルMVP) + +`mart.rideoasis_supply_points` をローカル `sqlite` にエクスポートし、GPX 経路の近傍にある補給地点を OpenLayers で確認できます。 + +1. BigQuery からローカル DB を作る + +```bash +npm run export:map-db -- \ + --project your-gcp-project \ + --output ./.local/rideoasis-map.db +``` + +2. ローカルサーバを起動する + +```bash +npm run map:serve -- --db ./.local/rideoasis-map.db --port 8787 +``` + +3. ブラウザで `http://localhost:8787` を開き、GPX ファイルを選ぶ + +MVP の仕様: + +- API は `GET /api/supply-points` を返します +- サーバ側では bbox / chain / `min_point_level` で絞り込みます +- 経路からの最短距離判定はブラウザ側で行います +- 近傍距離はメートルで調整できます + ## 注意事項 - クローリング対象サイトの利用規約/robots を確認し、アクセス頻度を制限します。 diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..ee7d77a --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,338 @@ +const API_BASE = window.RIDEOASIS_API_BASE || '/api'; + +const elements = { + status: document.getElementById('status'), + gpxFile: document.getElementById('gpx-file'), + distanceThreshold: document.getElementById('distance-threshold'), + minPointLevel: document.getElementById('min-point-level'), + refresh: document.getElementById('refresh'), + pointList: document.getElementById('point-list'), + routePointCount: document.getElementById('route-point-count'), + candidateCount: document.getElementById('candidate-count'), + matchedCount: document.getElementById('matched-count'), + popup: document.getElementById('popup'), + popupBody: document.getElementById('popup-body'), + popupClose: document.getElementById('popup-close') +}; + +const routeGeoJsonFormat = new ol.format.GeoJSON({ featureProjection: 'EPSG:3857' }); + +const routeSource = new ol.source.Vector(); +const pointSource = new ol.source.Vector(); +const endpointSource = new ol.source.Vector(); + +const routeLayer = new ol.layer.Vector({ + source: routeSource, + style: new ol.style.Style({ + stroke: new ol.style.Stroke({ + color: '#225ea8', + width: 4 + }) + }) +}); + +const pointLayer = new ol.layer.Vector({ + source: pointSource, + style(feature) { + const active = feature.get('active') === true; + return new ol.style.Style({ + image: new ol.style.Circle({ + radius: active ? 8 : 6, + fill: new ol.style.Fill({ color: active ? '#9e3d22' : '#12836b' }), + stroke: new ol.style.Stroke({ color: '#ffffff', width: 1.5 }) + }) + }); + } +}); + +const endpointLayer = new ol.layer.Vector({ + source: endpointSource, + style(feature) { + const kind = feature.get('kind'); + const fill = kind === 'start' ? '#0b8f3f' : '#c62f2f'; + const points = kind === 'start' ? 3 : 4; + const angle = kind === 'start' ? Math.PI / 2 : Math.PI / 4; + return new ol.style.Style({ + image: new ol.style.RegularShape({ + points, + radius: 8, + angle, + fill: new ol.style.Fill({ color: fill }), + stroke: new ol.style.Stroke({ color: '#fff', width: 1.2 }) + }) + }); + } +}); + +const map = new ol.Map({ + target: 'map', + layers: [ + new ol.layer.Tile({ source: new ol.source.OSM() }), + routeLayer, + pointLayer, + endpointLayer + ], + view: new ol.View({ + center: ol.proj.fromLonLat([139.767, 35.681]), + zoom: 6 + }) +}); + +const popupOverlay = new ol.Overlay({ + element: elements.popup, + positioning: 'bottom-left', + stopEvent: true, + offset: [12, -12] +}); +map.addOverlay(popupOverlay); + +let routeFeature = null; +let routeCoordinates = []; +let matchedPoints = []; +let activeSupplyPointId = null; + +function setStatus(message) { + elements.status.textContent = message; +} + +function selectedChains() { + return Array.from(document.querySelectorAll('.chains input[type="checkbox"]:checked')).map( + (input) => input.value + ); +} + +function buildPointList(points) { + elements.pointList.innerHTML = ''; + if (points.length === 0) { + const empty = document.createElement('div'); + empty.className = 'point-item'; + empty.textContent = '近傍の補給地点は見つかりませんでした'; + elements.pointList.appendChild(empty); + return; + } + + for (const feature of points) { + const props = feature.properties; + const item = document.createElement('button'); + item.type = 'button'; + item.className = 'point-item'; + if (props.supply_point_id === activeSupplyPointId) { + item.classList.add('active'); + } + item.innerHTML = [ + `
${escapeHtml(props.name)}
`, + `
${escapeHtml(props.chain)} · ${Math.round(props.route_distance_m)}m
`, + `
${escapeHtml(props.address_norm || '-')}
` + ].join(''); + item.addEventListener('click', () => activatePoint(props.supply_point_id)); + elements.pointList.appendChild(item); + } +} + +function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +function buildPopupHtml(props) { + const link = props.source_url + ? `source` + : '-'; + return [ + `${escapeHtml(props.name)}`, + `
chain: ${escapeHtml(props.chain)}
`, + `
distance: ${Math.round(props.route_distance_m)}m
`, + `
point_level: ${escapeHtml(props.geocode_point_level ?? '-')}
`, + `
updated_at: ${escapeHtml(props.updated_at || '-')}
`, + `
${escapeHtml(props.address_norm || '-')}
`, + `
${link}
` + ].join(''); +} + +function activatePoint(supplyPointId) { + activeSupplyPointId = supplyPointId; + for (const feature of pointSource.getFeatures()) { + const isActive = feature.get('properties').supply_point_id === supplyPointId; + feature.set('active', isActive); + if (isActive) { + popupOverlay.setPosition(feature.getGeometry().getCoordinates()); + elements.popupBody.innerHTML = buildPopupHtml(feature.get('properties')); + elements.popup.hidden = false; + } + } + buildPointList(matchedPoints); +} + +function clearPopup() { + activeSupplyPointId = null; + elements.popup.hidden = true; + popupOverlay.setPosition(undefined); + for (const feature of pointSource.getFeatures()) { + feature.set('active', false); + } + buildPointList(matchedPoints); +} + +function updateSummary(candidateCount, matchedCount) { + elements.routePointCount.textContent = routeCoordinates.length ? String(routeCoordinates.length) : '-'; + elements.candidateCount.textContent = String(candidateCount); + elements.matchedCount.textContent = String(matchedCount); +} + +function renderRoute(feature) { + routeSource.clear(); + endpointSource.clear(); + routeSource.addFeature(feature); + + const coordinates = feature.getGeometry().getCoordinates(); + if (coordinates.length >= 2) { + const start = new ol.Feature({ + geometry: new ol.geom.Point(coordinates[0]) + }); + start.set('kind', 'start'); + const goal = new ol.Feature({ + geometry: new ol.geom.Point(coordinates[coordinates.length - 1]) + }); + goal.set('kind', 'goal'); + endpointSource.addFeatures([start, goal]); + } +} + +function renderMatchedPoints(points) { + pointSource.clear(); + const features = points.map((feature) => { + const olFeature = new ol.Feature({ + geometry: new ol.geom.Point(ol.proj.fromLonLat(feature.geometry.coordinates)) + }); + olFeature.set('properties', feature.properties); + olFeature.set('active', feature.properties.supply_point_id === activeSupplyPointId); + return olFeature; + }); + pointSource.addFeatures(features); +} + +function fitToVisibleData() { + const extent = ol.extent.createEmpty(); + let hasData = false; + for (const source of [routeSource, pointSource, endpointSource]) { + const sourceExtent = source.getExtent(); + if (sourceExtent && !ol.extent.isEmpty(sourceExtent)) { + ol.extent.extend(extent, sourceExtent); + hasData = true; + } + } + if (hasData) { + map.getView().fit(extent, { padding: [40, 40, 40, 40], duration: 250, maxZoom: 14 }); + } +} + +function createRouteFeatureFromGpx(gpxText) { + const parsed = window.GpxParser.parseGpxText(gpxText); + routeCoordinates = parsed.geometry.coordinates; + return routeGeoJsonFormat.readFeature(parsed); +} + +function expandedBboxForQuery(distanceMeters) { + const routeBbox = window.RouteMath.computeBbox(routeCoordinates); + return window.RouteMath.expandBbox(routeBbox, Math.max(distanceMeters, 2000)); +} + +async function fetchCandidatePoints(distanceMeters) { + const chains = selectedChains(); + const minPointLevel = Number(elements.minPointLevel.value) || 8; + const bbox = expandedBboxForQuery(distanceMeters); + const params = new URLSearchParams(); + if (bbox) { + params.set('bbox', bbox.join(',')); + } + if (chains.length > 0) { + params.set('chains', chains.join(',')); + } + params.set('min_point_level', String(minPointLevel)); + params.set('limit', '10000'); + + const response = await fetch(`${API_BASE}/supply-points?${params.toString()}`); + if (!response.ok) { + throw new Error(`API error (${response.status})`); + } + return response.json(); +} + +function filterMatchedPoints(featureCollection, distanceMeters) { + return featureCollection.features + .map((feature) => { + const distance = window.RouteMath.pointToRouteDistanceMeters( + feature.geometry.coordinates, + routeCoordinates + ); + return { + ...feature, + properties: { + ...feature.properties, + route_distance_m: distance + } + }; + }) + .filter((feature) => feature.properties.route_distance_m <= distanceMeters) + .sort((a, b) => a.properties.route_distance_m - b.properties.route_distance_m); +} + +async function refreshMap() { + if (!routeCoordinates.length) { + setStatus('先に GPX を読み込んでください'); + return; + } + + clearPopup(); + const distanceMeters = Math.max(100, Number(elements.distanceThreshold.value) || 1000); + setStatus('補給地点を検索中...'); + + try { + const candidates = await fetchCandidatePoints(distanceMeters); + matchedPoints = filterMatchedPoints(candidates, distanceMeters); + renderMatchedPoints(matchedPoints); + buildPointList(matchedPoints); + updateSummary(candidates.features.length, matchedPoints.length); + fitToVisibleData(); + setStatus(`${matchedPoints.length} 件の補給地点が ${distanceMeters}m 以内にあります`); + } catch (error) { + setStatus('補給地点の取得に失敗しました'); + console.error(error); + } +} + +async function handleGpxFile(event) { + const file = event.target.files?.[0]; + if (!file) return; + try { + const gpxText = await file.text(); + routeFeature = createRouteFeatureFromGpx(gpxText); + renderRoute(routeFeature); + updateSummary(0, 0); + fitToVisibleData(); + setStatus(`GPX を読み込みました: ${file.name}`); + await refreshMap(); + } catch (error) { + setStatus(error?.message || 'GPX の読み込みに失敗しました'); + console.error(error); + } +} + +function bindEvents() { + elements.gpxFile.addEventListener('change', handleGpxFile); + elements.refresh.addEventListener('click', refreshMap); + elements.popupClose.addEventListener('click', clearPopup); + map.on('singleclick', (event) => { + const feature = map.forEachFeatureAtPixel(event.pixel, (candidate) => candidate); + if (!feature || !feature.get('properties')) { + clearPopup(); + return; + } + activatePoint(feature.get('properties').supply_point_id); + }); +} + +bindEvents(); diff --git a/frontend/gpx.js b/frontend/gpx.js new file mode 100644 index 0000000..53cb15e --- /dev/null +++ b/frontend/gpx.js @@ -0,0 +1,44 @@ +(function (root, factory) { + if (typeof module === 'object' && module.exports) { + module.exports = factory(); + return; + } + root.GpxParser = factory(); +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + const POINT_RE = /<(trkpt|rtept)\b[^>]*?\blat="([^"]+)"[^>]*?\blon="([^"]+)"[^>]*?>/g; + + function parseCoordinateTokens(gpxText) { + const coords = []; + let match; + while ((match = POINT_RE.exec(gpxText)) !== null) { + const lat = Number(match[2]); + const lng = Number(match[3]); + if (Number.isFinite(lat) && Number.isFinite(lng)) { + coords.push([lng, lat]); + } + } + return coords; + } + + function parseGpxText(gpxText) { + const coordinates = parseCoordinateTokens(String(gpxText || '')); + if (coordinates.length < 2) { + throw new Error('GPX から2点以上の経路座標を抽出できませんでした'); + } + return { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates + }, + properties: { + point_count: coordinates.length + } + }; + } + + return { + parseCoordinateTokens, + parseGpxText + }; +}); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d2d1889 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,86 @@ + + + + + + RideOasis Supply Point Map + + + + +
+
+
+

RideOasis Map

+

GPX 経路の近傍にある補給地点を可視化

+
+
GPX を読み込んでください
+
+ +
+ + + + + + +
+ チェーン + + + + + + +
+ + +
+ +
+ +
+
+ +
+
+
+ + + + + + + + diff --git a/frontend/route_math.js b/frontend/route_math.js new file mode 100644 index 0000000..7e520d1 --- /dev/null +++ b/frontend/route_math.js @@ -0,0 +1,108 @@ +(function (root, factory) { + if (typeof module === 'object' && module.exports) { + module.exports = factory(); + return; + } + root.RouteMath = factory(); +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + function toRadians(value) { + return (value * Math.PI) / 180; + } + + function meanLatitude(coords) { + if (!coords.length) return 0; + return coords.reduce((sum, coord) => sum + coord[1], 0) / coords.length; + } + + function projectLonLatToMeters(coord, referenceLat) { + const lng = coord[0]; + const lat = coord[1]; + const earthRadius = 6371008.8; + const x = earthRadius * toRadians(lng) * Math.cos(toRadians(referenceLat)); + const y = earthRadius * toRadians(lat); + return [x, y]; + } + + function distancePointToSegmentMeters(point, start, end, referenceLat) { + const p = projectLonLatToMeters(point, referenceLat); + const a = projectLonLatToMeters(start, referenceLat); + const b = projectLonLatToMeters(end, referenceLat); + const abx = b[0] - a[0]; + const aby = b[1] - a[1]; + const apx = p[0] - a[0]; + const apy = p[1] - a[1]; + const ab2 = abx * abx + aby * aby; + if (ab2 === 0) { + const dx = p[0] - a[0]; + const dy = p[1] - a[1]; + return Math.hypot(dx, dy); + } + const t = Math.max(0, Math.min(1, (apx * abx + apy * aby) / ab2)); + const closestX = a[0] + abx * t; + const closestY = a[1] + aby * t; + return Math.hypot(p[0] - closestX, p[1] - closestY); + } + + function pointToRouteDistanceMeters(point, coordinates) { + if (!Array.isArray(coordinates) || coordinates.length < 2) { + return Number.POSITIVE_INFINITY; + } + const referenceLat = meanLatitude(coordinates); + let minDistance = Number.POSITIVE_INFINITY; + for (let i = 1; i < coordinates.length; i += 1) { + const distance = distancePointToSegmentMeters( + point, + coordinates[i - 1], + coordinates[i], + referenceLat + ); + if (distance < minDistance) { + minDistance = distance; + } + } + return minDistance; + } + + function metersToDegreePadding(latitude, meters) { + const latPadding = meters / 111320; + const cosLat = Math.cos(toRadians(latitude)); + const lngPadding = meters / (111320 * Math.max(cosLat, 0.01)); + return { latPadding, lngPadding }; + } + + function computeBbox(coordinates) { + if (!coordinates.length) return null; + let minLng = coordinates[0][0]; + let minLat = coordinates[0][1]; + let maxLng = coordinates[0][0]; + let maxLat = coordinates[0][1]; + + for (const coord of coordinates) { + minLng = Math.min(minLng, coord[0]); + minLat = Math.min(minLat, coord[1]); + maxLng = Math.max(maxLng, coord[0]); + maxLat = Math.max(maxLat, coord[1]); + } + + return [minLng, minLat, maxLng, maxLat]; + } + + function expandBbox(bbox, meters) { + if (!bbox) return null; + const [, minLat, , maxLat] = bbox; + const centerLat = (minLat + maxLat) / 2; + const padding = metersToDegreePadding(centerLat, meters); + return [ + bbox[0] - padding.lngPadding, + bbox[1] - padding.latPadding, + bbox[2] + padding.lngPadding, + bbox[3] + padding.latPadding + ]; + } + + return { + computeBbox, + expandBbox, + pointToRouteDistanceMeters + }; +}); diff --git a/frontend/styles.css b/frontend/styles.css new file mode 100644 index 0000000..c370023 --- /dev/null +++ b/frontend/styles.css @@ -0,0 +1,255 @@ +:root { + --bg: #efe7da; + --panel: rgba(255, 251, 244, 0.95); + --panel-strong: #fffdf7; + --ink: #1f1a14; + --muted: #6d6458; + --accent: #9e3d22; + --accent-deep: #6d2712; + --line: #d7c7b1; + --route: #225ea8; + --point: #12836b; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + color: var(--ink); + font-family: "Space Grotesk", "Hiragino Sans", sans-serif; + background: + radial-gradient(circle at top left, rgba(255, 255, 255, 0.65), transparent 35%), + linear-gradient(180deg, #f7f0e4, var(--bg)); +} + +.app { + min-height: 100vh; + padding: 18px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.topbar, +.panel, +.sidebar, +.popup { + border: 1px solid var(--line); + background: var(--panel); + backdrop-filter: blur(10px); +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 14px 18px; +} + +.topbar h1 { + margin: 0; + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 26px; +} + +.subtitle { + margin: 4px 0 0; + color: var(--muted); + font-size: 13px; +} + +.status { + font-size: 12px; + padding: 7px 10px; + border: 1px solid var(--line); + background: #f6eee1; + max-width: 340px; +} + +.controls { + display: grid; + grid-template-columns: repeat(4, minmax(120px, 1fr)) auto; + gap: 12px; + padding: 14px; + align-items: end; +} + +.controls label, +.chains { + display: flex; + flex-direction: column; + gap: 5px; + font-size: 12px; + color: var(--muted); +} + +.controls input[type="number"], +.controls input[type="file"] { + width: 100%; +} + +.controls input, +.controls button { + border: 1px solid var(--line); + background: #fff; + color: var(--ink); + padding: 9px 10px; + font: inherit; +} + +.controls button { + background: var(--accent); + color: #fff; + cursor: pointer; + min-width: 120px; +} + +.controls button:hover { + background: var(--accent-deep); +} + +.chains { + display: grid; + grid-template-columns: repeat(2, minmax(110px, 1fr)); + gap: 6px 10px; + padding: 8px 10px 10px; +} + +.chains legend { + padding: 0 4px; + color: var(--ink); +} + +.chains label { + flex-direction: row; + align-items: center; + gap: 6px; +} + +.content { + display: grid; + grid-template-columns: 280px 1fr; + gap: 12px; + min-height: 600px; +} + +.sidebar { + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.summary-card { + border: 1px solid var(--line); + background: var(--panel-strong); + padding: 10px 12px; +} + +.summary-label { + font-size: 12px; + color: var(--muted); +} + +.summary-value { + margin-top: 4px; + font-size: 24px; + font-weight: 700; +} + +.point-list { + display: flex; + flex-direction: column; + gap: 8px; + min-height: 0; + overflow: auto; +} + +.point-item { + border: 1px solid var(--line); + background: #fff; + padding: 10px; + cursor: pointer; +} + +.point-item:hover, +.point-item.active { + border-color: var(--point); + box-shadow: inset 0 0 0 1px var(--point); +} + +.point-item .title { + font-size: 14px; + font-weight: 600; +} + +.point-item .meta { + margin-top: 4px; + font-size: 12px; + color: var(--muted); +} + +.map-shell { + position: relative; + min-height: 600px; +} + +.map { + height: 100%; + min-height: 600px; + border: 1px solid var(--line); +} + +.popup { + position: absolute; + left: 16px; + bottom: 16px; + width: min(320px, calc(100% - 32px)); + padding: 12px 14px; + background: rgba(255, 253, 247, 0.97); + box-shadow: 0 10px 24px rgba(54, 35, 19, 0.12); +} + +.popup[hidden] { + display: none; +} + +.popup-close { + position: absolute; + top: 8px; + right: 8px; + border: 0; + background: transparent; + font-size: 20px; + cursor: pointer; +} + +.popup-body { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; +} + +.popup-body a { + color: var(--accent); +} + +@media (max-width: 1000px) { + .controls { + grid-template-columns: 1fr 1fr; + } + + .content { + grid-template-columns: 1fr; + } + + .map, + .map-shell { + min-height: 440px; + } +} diff --git a/lib/map_data.js b/lib/map_data.js new file mode 100644 index 0000000..582eae7 --- /dev/null +++ b/lib/map_data.js @@ -0,0 +1,368 @@ +const DEFAULT_MART_DATASET = 'rideoasis_mart'; +const DEFAULT_MART_TABLE = 'rideoasis_supply_points'; +const DEFAULT_SQLITE_PATH = '.local/rideoasis-map.db'; +const DEFAULT_API_PORT = 8787; +const DEFAULT_MIN_POINT_LEVEL = 8; +const DEFAULT_LIMIT = 5000; + +const SQLITE_COLUMNS = [ + 'supply_point_id', + 'chain', + 'store_id', + 'name', + 'lat', + 'lng', + 'address_norm', + 'geocode_level', + 'geocode_point_level', + 'source_url', + 'updated_at' +]; + +function sanitizeSqlitePath(value) { + if (!value) { + throw new Error('sqlite path is required'); + } + return String(value); +} + +function readValue(argv, index, flag) { + const value = argv[index + 1]; + if (!value || String(value).startsWith('-')) { + throw new Error(`${flag} requires a value`); + } + return value; +} + +function parseExportArgs(argv = process.argv) { + const args = { + project: null, + dataset: DEFAULT_MART_DATASET, + table: DEFAULT_MART_TABLE, + output: DEFAULT_SQLITE_PATH, + location: null, + dryRun: false + }; + + for (let i = 2; i < argv.length; i += 1) { + const token = argv[i]; + if (token === '--project') { + args.project = readValue(argv, i, '--project'); + i += 1; + continue; + } + if (token === '--dataset') { + args.dataset = readValue(argv, i, '--dataset'); + i += 1; + continue; + } + if (token === '--table') { + args.table = readValue(argv, i, '--table'); + i += 1; + continue; + } + if (token === '--output') { + args.output = readValue(argv, i, '--output'); + i += 1; + continue; + } + if (token === '--location') { + args.location = readValue(argv, i, '--location'); + i += 1; + continue; + } + if (token === '--dry-run') { + args.dryRun = true; + continue; + } + if (token === '--help' || token === '-h') { + return { help: true }; + } + throw new Error(`unknown arg: ${token}`); + } + + if (!args.project) throw new Error('--project is required'); + + return { help: false, ...args }; +} + +function parseServerArgs(argv = process.argv) { + const args = { + db: DEFAULT_SQLITE_PATH, + port: DEFAULT_API_PORT + }; + + for (let i = 2; i < argv.length; i += 1) { + const token = argv[i]; + if (token === '--db') { + args.db = readValue(argv, i, '--db'); + i += 1; + continue; + } + if (token === '--port') { + const value = Number(readValue(argv, i, '--port')); + if (!Number.isInteger(value) || value <= 0) { + throw new Error('--port must be a positive integer'); + } + args.port = value; + i += 1; + continue; + } + if (token === '--help' || token === '-h') { + return { help: true }; + } + throw new Error(`unknown arg: ${token}`); + } + + return { help: false, ...args }; +} + +function sanitizeId(value, label) { + if (!/^[A-Za-z0-9_]+$/.test(value)) { + throw new Error(`invalid ${label}: ${value}`); + } + return value; +} + +function buildBqSelectSql(project, dataset, table) { + const safeDataset = sanitizeId(dataset, 'dataset'); + const safeTable = sanitizeId(table, 'table'); + return [ + 'SELECT', + ' supply_point_id,', + ' chain,', + ' store_id,', + ' name,', + ' lat,', + ' lng,', + ' address_norm,', + ' geocode_level,', + ' geocode_point_level,', + ' source_url,', + ' updated_at', + `FROM \`${project}.${safeDataset}.${safeTable}\``, + 'WHERE lat IS NOT NULL AND lng IS NOT NULL', + 'ORDER BY supply_point_id' + ].join('\n'); +} + +function createSchemaSql() { + return [ + 'CREATE TABLE IF NOT EXISTS supply_points (', + ' supply_point_id TEXT PRIMARY KEY,', + ' chain TEXT NOT NULL,', + ' store_id TEXT NOT NULL,', + ' name TEXT NOT NULL,', + ' lat REAL NOT NULL,', + ' lng REAL NOT NULL,', + ' address_norm TEXT,', + ' geocode_level INTEGER,', + ' geocode_point_level INTEGER,', + ' source_url TEXT,', + ' updated_at TEXT', + ')', + ';', + 'CREATE INDEX IF NOT EXISTS idx_supply_points_chain ON supply_points(chain);', + 'CREATE INDEX IF NOT EXISTS idx_supply_points_point_level ON supply_points(geocode_point_level);', + 'CREATE INDEX IF NOT EXISTS idx_supply_points_lat_lng ON supply_points(lat, lng);' + ].join('\n'); +} + +function normalizePointRow(row) { + if (!row || row.lat == null || row.lng == null) { + return null; + } + + return { + supply_point_id: String(row.supply_point_id), + chain: String(row.chain), + store_id: String(row.store_id), + name: String(row.name || row.supply_point_id), + lat: Number(row.lat), + lng: Number(row.lng), + address_norm: row.address_norm == null ? null : String(row.address_norm), + geocode_level: row.geocode_level == null ? null : Number(row.geocode_level), + geocode_point_level: row.geocode_point_level == null ? null : Number(row.geocode_point_level), + source_url: row.source_url == null ? null : String(row.source_url), + updated_at: row.updated_at == null ? null : String(row.updated_at) + }; +} + +function createUpsertStatement(database) { + return database.prepare(` + INSERT INTO supply_points ( + supply_point_id, + chain, + store_id, + name, + lat, + lng, + address_norm, + geocode_level, + geocode_point_level, + source_url, + updated_at + ) VALUES ( + :supply_point_id, + :chain, + :store_id, + :name, + :lat, + :lng, + :address_norm, + :geocode_level, + :geocode_point_level, + :source_url, + :updated_at + ) + ON CONFLICT(supply_point_id) DO UPDATE SET + chain = excluded.chain, + store_id = excluded.store_id, + name = excluded.name, + lat = excluded.lat, + lng = excluded.lng, + address_norm = excluded.address_norm, + geocode_level = excluded.geocode_level, + geocode_point_level = excluded.geocode_point_level, + source_url = excluded.source_url, + updated_at = excluded.updated_at + `); +} + +function parseBBox(value) { + if (!value) return null; + const parts = String(value).split(',').map((v) => Number(v.trim())); + if (parts.length !== 4 || parts.some((v) => !Number.isFinite(v))) { + throw new Error('bbox must be minLng,minLat,maxLng,maxLat'); + } + const [minLng, minLat, maxLng, maxLat] = parts; + if (minLng > maxLng || minLat > maxLat) { + throw new Error('bbox min values must be <= max values'); + } + return { minLng, minLat, maxLng, maxLat }; +} + +function parseChains(value) { + if (!value) return []; + return String(value) + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + .map((item) => sanitizeId(item, 'chain')); +} + +function parsePositiveInt(value, label, fallback) { + if (value == null || value === '') return fallback; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${label} must be a positive integer`); + } + return parsed; +} + +function parseSupplyPointFilters(searchParams) { + return { + bbox: parseBBox(searchParams.get('bbox')), + chains: parseChains(searchParams.get('chains')), + minPointLevel: parsePositiveInt( + searchParams.get('min_point_level'), + 'min_point_level', + DEFAULT_MIN_POINT_LEVEL + ), + limit: parsePositiveInt(searchParams.get('limit'), 'limit', DEFAULT_LIMIT) + }; +} + +function buildSupplyPointsQuery(filters) { + const where = []; + const params = {}; + + if (filters.bbox) { + where.push('lng BETWEEN :minLng AND :maxLng'); + where.push('lat BETWEEN :minLat AND :maxLat'); + params.minLng = filters.bbox.minLng; + params.maxLng = filters.bbox.maxLng; + params.minLat = filters.bbox.minLat; + params.maxLat = filters.bbox.maxLat; + } + + if (Number.isInteger(filters.minPointLevel)) { + where.push('(geocode_point_level IS NOT NULL AND geocode_point_level >= :minPointLevel)'); + params.minPointLevel = filters.minPointLevel; + } + + if (filters.chains.length > 0) { + const placeholders = filters.chains.map((_, index) => `:chain${index}`); + where.push(`chain IN (${placeholders.join(', ')})`); + filters.chains.forEach((chain, index) => { + params[`chain${index}`] = chain; + }); + } + + params.limit = filters.limit; + + return { + sql: [ + 'SELECT', + ` ${SQLITE_COLUMNS.join(', ')}`, + 'FROM supply_points', + where.length > 0 ? `WHERE ${where.join(' AND ')}` : '', + 'ORDER BY chain, name, supply_point_id', + 'LIMIT :limit' + ] + .filter(Boolean) + .join('\n'), + params + }; +} + +function toGeoJsonFeature(row) { + return { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [row.lng, row.lat] + }, + properties: { + supply_point_id: row.supply_point_id, + chain: row.chain, + store_id: row.store_id, + name: row.name, + address_norm: row.address_norm, + geocode_level: row.geocode_level, + geocode_point_level: row.geocode_point_level, + source_url: row.source_url, + updated_at: row.updated_at + } + }; +} + +function toFeatureCollection(rows) { + return { + type: 'FeatureCollection', + features: rows.map(toGeoJsonFeature) + }; +} + +module.exports = { + DEFAULT_API_PORT, + DEFAULT_LIMIT, + DEFAULT_MART_DATASET, + DEFAULT_MART_TABLE, + DEFAULT_MIN_POINT_LEVEL, + DEFAULT_SQLITE_PATH, + SQLITE_COLUMNS, + buildBqSelectSql, + buildSupplyPointsQuery, + createSchemaSql, + createUpsertStatement, + normalizePointRow, + parseBBox, + parseChains, + parseExportArgs, + parseServerArgs, + parseSupplyPointFilters, + sanitizeId, + sanitizeSqlitePath, + toFeatureCollection +}; diff --git a/package.json b/package.json index b3d0af4..f122bdb 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "geocode:all": "bash scripts/geocode_all.sh", "publish:all": "bash scripts/publish_geocoded_and_dataform.sh", "geocode:ndjson": "node scripts/geocode_stores_ndjson.js", - "bq:upsert:geocoded": "node scripts/bq_upsert_geocoded.js" + "bq:upsert:geocoded": "node scripts/bq_upsert_geocoded.js", + "export:map-db": "node scripts/export_map_db.js", + "map:serve": "node scripts/map_dev_server.js" }, "repository": { "type": "git", diff --git a/scripts/export_map_db.js b/scripts/export_map_db.js new file mode 100644 index 0000000..5ea4139 --- /dev/null +++ b/scripts/export_map_db.js @@ -0,0 +1,109 @@ +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); +const { DatabaseSync } = require('node:sqlite'); + +const { + buildBqSelectSql, + createSchemaSql, + createUpsertStatement, + normalizePointRow, + parseExportArgs, + sanitizeSqlitePath +} = require('../lib/map_data'); + +function printHelp() { + console.log([ + 'Usage:', + ' node scripts/export_map_db.js --project [--dataset rideoasis_mart] [--table rideoasis_supply_points] [--output .local/rideoasis-map.db] [--location asia-northeast1] [--dry-run]', + '', + 'Example:', + ' node scripts/export_map_db.js --project my-project --output .local/rideoasis-map.db' + ].join('\n')); +} + +function buildBqArgs(options) { + const args = ['--project_id', options.project]; + if (options.location) { + args.push('--location', options.location); + } + args.push('query', '--use_legacy_sql=false', '--format=json', '--max_rows=1000000000'); + args.push(buildBqSelectSql(options.project, options.dataset, options.table)); + return args; +} + +function fetchMartRows(options) { + const args = buildBqArgs(options); + const output = execFileSync('bq', args, { + encoding: 'utf8', + maxBuffer: 256 * 1024 * 1024 + }); + return JSON.parse(output); +} + +function ensureParentDirectory(filePath) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +} + +function writeRowsToSqlite(rows, outputPath) { + ensureParentDirectory(outputPath); + const database = new DatabaseSync(outputPath); + database.exec(createSchemaSql()); + + const upsert = createUpsertStatement(database); + let count = 0; + + database.exec('BEGIN'); + try { + database.exec('DELETE FROM supply_points'); + for (const row of rows) { + const normalized = normalizePointRow(row); + if (!normalized) continue; + upsert.run(normalized); + count += 1; + } + database.exec('COMMIT'); + } catch (error) { + database.exec('ROLLBACK'); + throw error; + } finally { + database.close(); + } + + return count; +} + +function main() { + const args = parseExportArgs(process.argv); + if (args.help) { + printHelp(); + return; + } + + const outputPath = sanitizeSqlitePath(args.output); + const querySql = buildBqSelectSql(args.project, args.dataset, args.table); + + if (args.dryRun) { + console.log(querySql); + return; + } + + const rows = fetchMartRows(args); + const count = writeRowsToSqlite(rows, outputPath); + console.log(`exported ${count} supply points to ${outputPath}`); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error?.message || error); + process.exit(1); + } +} + +module.exports = { + buildBqArgs, + fetchMartRows, + writeRowsToSqlite +}; diff --git a/scripts/map_dev_server.js b/scripts/map_dev_server.js new file mode 100644 index 0000000..87f8cc2 --- /dev/null +++ b/scripts/map_dev_server.js @@ -0,0 +1,139 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const http = require('node:http'); +const { DatabaseSync } = require('node:sqlite'); + +const { + parseServerArgs, + parseSupplyPointFilters, + buildSupplyPointsQuery, + toFeatureCollection +} = require('../lib/map_data'); + +const FRONTEND_DIR = path.join(__dirname, '..', 'frontend'); +const MIME_TYPES = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.gpx': 'application/gpx+xml; charset=utf-8' +}; + +function printHelp() { + console.log([ + 'Usage:', + ' node scripts/map_dev_server.js [--db .local/rideoasis-map.db] [--port 8787]', + '', + 'Serves the frontend and GET /api/supply-points from a local SQLite DB.' + ].join('\n')); +} + +function createApiHandler(database) { + return function handleSupplyPoints(request, requestUrl, response) { + try { + const filters = parseSupplyPointFilters(requestUrl.searchParams); + const { sql, params } = buildSupplyPointsQuery(filters); + const rows = database.prepare(sql).all(params); + const payload = JSON.stringify(toFeatureCollection(rows)); + response.writeHead(200, { 'content-type': 'application/geo+json; charset=utf-8' }); + response.end(request.method === 'HEAD' ? '' : payload); + } catch (error) { + response.writeHead(400, { 'content-type': 'application/json; charset=utf-8' }); + response.end(request.method === 'HEAD' ? '' : JSON.stringify({ error: error?.message || String(error) })); + } + }; +} + +function resolveStaticPath(requestPathname) { + const relative = requestPathname === '/' ? '/index.html' : requestPathname; + const normalized = path + .normalize(relative) + .replace(/^[/\\]+/, '') + .replace(/^(\.\.[/\\])+/, ''); + return path.join(FRONTEND_DIR, normalized); +} + +function serveStaticFile(requestPathname, response) { + const filePath = resolveStaticPath(requestPathname); + if (!filePath.startsWith(FRONTEND_DIR)) { + response.writeHead(403); + response.end('forbidden'); + return; + } + + if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) { + response.writeHead(404); + response.end('not found'); + return; + } + + const ext = path.extname(filePath); + const mimeType = MIME_TYPES[ext] || 'application/octet-stream'; + response.writeHead(200, { 'content-type': mimeType }); + return fs.createReadStream(filePath).pipe(response); +} + +function createServer(database) { + const handleSupplyPoints = createApiHandler(database); + + return http.createServer((request, response) => { + const requestUrl = new URL(request.url, `http://${request.headers.host || 'localhost'}`); + + if ((request.method === 'GET' || request.method === 'HEAD') && requestUrl.pathname === '/api/supply-points') { + handleSupplyPoints(request, requestUrl, response); + return; + } + + if (request.method === 'HEAD') { + const filePath = resolveStaticPath(requestUrl.pathname); + if (!filePath.startsWith(FRONTEND_DIR) || !fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) { + response.writeHead(404); + response.end(); + return; + } + const ext = path.extname(filePath); + const mimeType = MIME_TYPES[ext] || 'application/octet-stream'; + response.writeHead(200, { 'content-type': mimeType }); + response.end(); + return; + } + + if (request.method === 'GET') { + serveStaticFile(requestUrl.pathname, response); + return; + } + + response.writeHead(405); + response.end('method not allowed'); + }); +} + +function main() { + const args = parseServerArgs(process.argv); + if (args.help) { + printHelp(); + return; + } + + const database = new DatabaseSync(args.db, { readOnly: true }); + const server = createServer(database); + server.listen(args.port, () => { + console.log(`ride-oasis map server listening on http://localhost:${args.port}`); + }); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error?.message || error); + process.exit(1); + } +} + +module.exports = { + FRONTEND_DIR, + createApiHandler, + createServer, + resolveStaticPath +}; diff --git a/tests/map_data.test.js b/tests/map_data.test.js new file mode 100644 index 0000000..7d616fa --- /dev/null +++ b/tests/map_data.test.js @@ -0,0 +1,163 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { DatabaseSync } = require('node:sqlite'); + +const { + buildBqSelectSql, + buildSupplyPointsQuery, + createSchemaSql, + createUpsertStatement, + normalizePointRow, + parseBBox, + parseExportArgs, + parseServerArgs, + parseSupplyPointFilters, + toFeatureCollection +} = require('../lib/map_data'); + +test('Map Data: export 用CLI引数を正常に解釈できる', () => { + const args = parseExportArgs([ + 'node', + 'scripts/export_map_db.js', + '--project', + 'rideoasis-dev', + '--output', + '.local/map.db' + ]); + + assert.equal(args.project, 'rideoasis-dev'); + assert.equal(args.output, '.local/map.db'); +}); + +test('Map Data: export 用CLIで --project が欠落している場合は例外を投げる', () => { + assert.throws(() => parseExportArgs(['node', 'x']), /--project is required/); +}); + +test('Map Data: server 用CLI引数を正常に解釈できる', () => { + const args = parseServerArgs(['node', 'scripts/map_dev_server.js', '--db', '.local/map.db', '--port', '9090']); + assert.equal(args.db, '.local/map.db'); + assert.equal(args.port, 9090); +}); + +test('Map Data: bbox を正常に解釈できる', () => { + assert.deepEqual(parseBBox('139.0,35.0,140.0,36.0'), { + minLng: 139.0, + minLat: 35.0, + maxLng: 140.0, + maxLat: 36.0 + }); +}); + +test('Map Data: 不正な bbox は例外を投げる', () => { + assert.throws(() => parseBBox('139,35,140'), /bbox must be/); +}); + +test('Map Data: supply point filters を既定値付きで解釈できる', () => { + const params = new URLSearchParams('bbox=139,35,140,36&chains=lawson,familymart'); + const filters = parseSupplyPointFilters(params); + assert.equal(filters.chains.length, 2); + assert.equal(filters.minPointLevel, 8); + assert.equal(filters.limit, 5000); +}); + +test('Map Data: BigQuery SELECT SQL に null 除外が含まれる', () => { + const sql = buildBqSelectSql('rideoasis-dev', 'rideoasis_mart', 'rideoasis_supply_points'); + assert.match(sql, /WHERE lat IS NOT NULL AND lng IS NOT NULL/); + assert.match(sql, /FROM `rideoasis-dev\.rideoasis_mart\.rideoasis_supply_points`/); +}); + +test('Map Data: export 用 bq query は 100 件上限を外す', () => { + const { buildBqArgs } = require('../scripts/export_map_db'); + const args = buildBqArgs({ + project: 'rideoasis-dev', + dataset: 'rideoasis_mart', + table: 'rideoasis_supply_points', + location: null + }); + assert.ok(args.includes('--max_rows=1000000000')); +}); + +test('Map Data: null 座標の行は normalize 時に除外される', () => { + assert.equal(normalizePointRow({ supply_point_id: 'x', chain: 'lawson', store_id: '1', lat: null, lng: 139 }), null); +}); + +test('Map Data: SQLite upsert で同一 supply_point_id を更新できる', () => { + const database = new DatabaseSync(':memory:'); + database.exec(createSchemaSql()); + const statement = createUpsertStatement(database); + + statement.run( + normalizePointRow({ + supply_point_id: 'lawson:1', + chain: 'lawson', + store_id: '1', + name: '旧店舗', + lat: 35.0, + lng: 139.0, + address_norm: '東京都', + geocode_level: 3, + geocode_point_level: 8, + source_url: 'https://example.com/old', + updated_at: '2026-03-19T00:00:00Z' + }) + ); + statement.run( + normalizePointRow({ + supply_point_id: 'lawson:1', + chain: 'lawson', + store_id: '1', + name: '新店舗', + lat: 35.1, + lng: 139.1, + address_norm: '東京都千代田区', + geocode_level: 8, + geocode_point_level: 8, + source_url: 'https://example.com/new', + updated_at: '2026-03-20T00:00:00Z' + }) + ); + + const row = database.prepare('SELECT name, lat, lng, source_url FROM supply_points WHERE supply_point_id = ?').get('lawson:1'); + assert.equal(row.name, '新店舗'); + assert.equal(row.lat, 35.1); + assert.equal(row.lng, 139.1); + assert.equal(row.source_url, 'https://example.com/new'); + database.close(); +}); + +test('Map Data: API query が bbox と chain と point_level を反映する', () => { + const { sql, params } = buildSupplyPointsQuery({ + bbox: { minLng: 139, minLat: 35, maxLng: 140, maxLat: 36 }, + chains: ['lawson', 'familymart'], + minPointLevel: 8, + limit: 123 + }); + + assert.match(sql, /lng BETWEEN :minLng AND :maxLng/); + assert.match(sql, /chain IN \(:chain0, :chain1\)/); + assert.match(sql, /geocode_point_level >= :minPointLevel/); + assert.equal(params.limit, 123); + assert.equal(params.chain0, 'lawson'); +}); + +test('Map Data: GeoJSON FeatureCollection を生成できる', () => { + const collection = toFeatureCollection([ + { + supply_point_id: 'familymart:1', + chain: 'familymart', + store_id: '1', + name: 'FM', + lat: 35.1, + lng: 139.2, + address_norm: '東京都', + geocode_level: 8, + geocode_point_level: 8, + source_url: null, + updated_at: '2026-03-19T00:00:00Z' + } + ]); + + assert.equal(collection.type, 'FeatureCollection'); + assert.equal(collection.features[0].geometry.type, 'Point'); + assert.deepEqual(collection.features[0].geometry.coordinates, [139.2, 35.1]); +}); diff --git a/tests/route_math.test.js b/tests/route_math.test.js new file mode 100644 index 0000000..779cc5d --- /dev/null +++ b/tests/route_math.test.js @@ -0,0 +1,62 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { computeBbox, expandBbox, pointToRouteDistanceMeters } = require('../frontend/route_math'); +const { parseCoordinateTokens, parseGpxText } = require('../frontend/gpx'); + +test('Route Math: GPX から trkpt を順序通り抽出できる', () => { + const coords = parseCoordinateTokens([ + '', + '', + '', + '' + ].join('')); + + assert.deepEqual(coords, [ + [139.0, 35.0], + [139.1, 35.1] + ]); +}); + +test('Route Math: 2点未満の GPX は例外を投げる', () => { + assert.throws(() => parseGpxText(''), /2点以上/); +}); + +test('Route Math: bbox を算出できる', () => { + assert.deepEqual( + computeBbox([ + [139.3, 35.3], + [139.0, 35.1], + [139.2, 35.5] + ]), + [139.0, 35.1, 139.3, 35.5] + ); +}); + +test('Route Math: bbox をメートル相当で拡張できる', () => { + const expanded = expandBbox([139.0, 35.0, 139.1, 35.1], 1000); + assert.ok(expanded[0] < 139.0); + assert.ok(expanded[2] > 139.1); +}); + +test('Route Math: 経路上の点はほぼゼロ距離になる', () => { + const distance = pointToRouteDistanceMeters( + [139.05, 35.0], + [ + [139.0, 35.0], + [139.1, 35.0] + ] + ); + assert.ok(distance < 1); +}); + +test('Route Math: 経路から離れた点は数百メートル以上になる', () => { + const distance = pointToRouteDistanceMeters( + [139.05, 35.01], + [ + [139.0, 35.0], + [139.1, 35.0] + ] + ); + assert.ok(distance > 500); +}); From 8cfbfcbc2d2c5d41355989aa1f45c09c854e01bf Mon Sep 17 00:00:00 2001 From: ochanuco Date: Fri, 20 Mar 2026 00:13:46 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E6=8C=87=E6=91=98=E3=81=A8CI=E5=89=8D=E6=8F=90=E3=82=92?= =?UTF-8?q?=E5=8F=8D=E6=98=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/unit-tests.yml | 2 +- README.md | 3 ++- frontend/app.js | 21 +++++++++++++--- frontend/gpx.js | 16 ++++++++---- lib/map_data.js | 42 +++++++++++++++++++++++--------- package.json | 3 +++ scripts/export_map_db.js | 6 ++++- scripts/map_dev_server.js | 13 ++++++++-- tests/map_data.test.js | 11 +++++++-- tests/route_math.test.js | 14 +++++++++++ 10 files changed, 104 insertions(+), 27 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 02ff394..2effed3 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: [20, 22] + node-version: [22, 24] steps: - name: Checkout diff --git a/README.md b/README.md index 83cde4a..e508ff3 100644 --- a/README.md +++ b/README.md @@ -154,9 +154,10 @@ npm run map:serve -- --db ./.local/rideoasis-map.db --port 8787 MVP の仕様: - API は `GET /api/supply-points` を返します -- サーバ側では bbox / chain / `min_point_level` で絞り込みます +- サーバ側では `bbox` / `chains` / `min_point_level` / `limit` で絞り込みます - 経路からの最短距離判定はブラウザ側で行います - 近傍距離はメートルで調整できます +- `node:sqlite` を使うため、ローカル実行は Node.js `>=22.5.0` 前提です ## 注意事項 diff --git a/frontend/app.js b/frontend/app.js index ee7d77a..5a2eb5f 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -134,12 +134,27 @@ function escapeHtml(value) { .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') - .replaceAll('"', '"'); + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function safeExternalUrl(value) { + if (!value) return null; + try { + const url = new URL(String(value)); + if (url.protocol === 'http:' || url.protocol === 'https:') { + return url.toString(); + } + return null; + } catch { + return null; + } } function buildPopupHtml(props) { - const link = props.source_url - ? `source` + const safeUrl = safeExternalUrl(props.source_url); + const link = safeUrl + ? `source` : '-'; return [ `${escapeHtml(props.name)}`, diff --git a/frontend/gpx.js b/frontend/gpx.js index 53cb15e..b5e76d9 100644 --- a/frontend/gpx.js +++ b/frontend/gpx.js @@ -5,14 +5,20 @@ } root.GpxParser = factory(); })(typeof globalThis !== 'undefined' ? globalThis : this, function () { - const POINT_RE = /<(trkpt|rtept)\b[^>]*?\blat="([^"]+)"[^>]*?\blon="([^"]+)"[^>]*?>/g; + const POINT_TAG_RE = /<(trkpt|rtept)\b([^>]*)>/gi; + const ATTR_RE = /\b(lat|lon)\s*=\s*(['"])(.*?)\2/gi; function parseCoordinateTokens(gpxText) { const coords = []; - let match; - while ((match = POINT_RE.exec(gpxText)) !== null) { - const lat = Number(match[2]); - const lng = Number(match[3]); + let tagMatch; + while ((tagMatch = POINT_TAG_RE.exec(String(gpxText || ''))) !== null) { + const attrs = {}; + let attrMatch; + while ((attrMatch = ATTR_RE.exec(tagMatch[2])) !== null) { + attrs[attrMatch[1]] = attrMatch[3]; + } + const lat = Number(attrs.lat); + const lng = Number(attrs.lon); if (Number.isFinite(lat) && Number.isFinite(lng)) { coords.push([lng, lat]); } diff --git a/lib/map_data.js b/lib/map_data.js index 582eae7..6d3d91b 100644 --- a/lib/map_data.js +++ b/lib/map_data.js @@ -5,6 +5,13 @@ const DEFAULT_API_PORT = 8787; const DEFAULT_MIN_POINT_LEVEL = 8; const DEFAULT_LIMIT = 5000; +class ValidationError extends Error { + constructor(message) { + super(message); + this.name = 'ValidationError'; + } +} + const SQLITE_COLUMNS = [ 'supply_point_id', 'chain', @@ -21,7 +28,7 @@ const SQLITE_COLUMNS = [ function sanitizeSqlitePath(value) { if (!value) { - throw new Error('sqlite path is required'); + throw new ValidationError('sqlite path is required'); } return String(value); } @@ -29,7 +36,7 @@ function sanitizeSqlitePath(value) { function readValue(argv, index, flag) { const value = argv[index + 1]; if (!value || String(value).startsWith('-')) { - throw new Error(`${flag} requires a value`); + throw new ValidationError(`${flag} requires a value`); } return value; } @@ -78,10 +85,10 @@ function parseExportArgs(argv = process.argv) { if (token === '--help' || token === '-h') { return { help: true }; } - throw new Error(`unknown arg: ${token}`); + throw new ValidationError(`unknown arg: ${token}`); } - if (!args.project) throw new Error('--project is required'); + if (!args.project) throw new ValidationError('--project is required'); return { help: false, ...args }; } @@ -102,7 +109,7 @@ function parseServerArgs(argv = process.argv) { if (token === '--port') { const value = Number(readValue(argv, i, '--port')); if (!Number.isInteger(value) || value <= 0) { - throw new Error('--port must be a positive integer'); + throw new ValidationError('--port must be a positive integer'); } args.port = value; i += 1; @@ -111,7 +118,7 @@ function parseServerArgs(argv = process.argv) { if (token === '--help' || token === '-h') { return { help: true }; } - throw new Error(`unknown arg: ${token}`); + throw new ValidationError(`unknown arg: ${token}`); } return { help: false, ...args }; @@ -119,12 +126,21 @@ function parseServerArgs(argv = process.argv) { function sanitizeId(value, label) { if (!/^[A-Za-z0-9_]+$/.test(value)) { - throw new Error(`invalid ${label}: ${value}`); + throw new ValidationError(`invalid ${label}: ${value}`); } return value; } +function validateProjectId(value) { + const projectId = String(value || ''); + if (!/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(projectId)) { + throw new ValidationError(`invalid project: ${projectId}`); + } + return projectId; +} + function buildBqSelectSql(project, dataset, table) { + const validatedProject = validateProjectId(project); const safeDataset = sanitizeId(dataset, 'dataset'); const safeTable = sanitizeId(table, 'table'); return [ @@ -140,7 +156,7 @@ function buildBqSelectSql(project, dataset, table) { ' geocode_point_level,', ' source_url,', ' updated_at', - `FROM \`${project}.${safeDataset}.${safeTable}\``, + `FROM \`${validatedProject}.${safeDataset}.${safeTable}\``, 'WHERE lat IS NOT NULL AND lng IS NOT NULL', 'ORDER BY supply_point_id' ].join('\n'); @@ -233,11 +249,11 @@ function parseBBox(value) { if (!value) return null; const parts = String(value).split(',').map((v) => Number(v.trim())); if (parts.length !== 4 || parts.some((v) => !Number.isFinite(v))) { - throw new Error('bbox must be minLng,minLat,maxLng,maxLat'); + throw new ValidationError('bbox must be minLng,minLat,maxLng,maxLat'); } const [minLng, minLat, maxLng, maxLat] = parts; if (minLng > maxLng || minLat > maxLat) { - throw new Error('bbox min values must be <= max values'); + throw new ValidationError('bbox min values must be <= max values'); } return { minLng, minLat, maxLng, maxLat }; } @@ -255,7 +271,7 @@ function parsePositiveInt(value, label, fallback) { if (value == null || value === '') return fallback; const parsed = Number(value); if (!Number.isInteger(parsed) || parsed <= 0) { - throw new Error(`${label} must be a positive integer`); + throw new ValidationError(`${label} must be a positive integer`); } return parsed; } @@ -364,5 +380,7 @@ module.exports = { parseSupplyPointFilters, sanitizeId, sanitizeSqlitePath, - toFeatureCollection + toFeatureCollection, + validateProjectId, + ValidationError }; diff --git a/package.json b/package.json index f122bdb..a93fbf9 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,9 @@ "export:map-db": "node scripts/export_map_db.js", "map:serve": "node scripts/map_dev_server.js" }, + "engines": { + "node": ">=22.5.0" + }, "repository": { "type": "git", "url": "git+ssh://git@github.com/ochanuco/ride-oasis.git" diff --git a/scripts/export_map_db.js b/scripts/export_map_db.js index 5ea4139..b7cc58d 100644 --- a/scripts/export_map_db.js +++ b/scripts/export_map_db.js @@ -12,6 +12,8 @@ const { sanitizeSqlitePath } = require('../lib/map_data'); +const DEFAULT_BQ_TIMEOUT_MS = 10 * 60 * 1000; + function printHelp() { console.log([ 'Usage:', @@ -36,7 +38,8 @@ function fetchMartRows(options) { const args = buildBqArgs(options); const output = execFileSync('bq', args, { encoding: 'utf8', - maxBuffer: 256 * 1024 * 1024 + maxBuffer: 256 * 1024 * 1024, + timeout: DEFAULT_BQ_TIMEOUT_MS }); return JSON.parse(output); } @@ -103,6 +106,7 @@ if (require.main === module) { } module.exports = { + DEFAULT_BQ_TIMEOUT_MS, buildBqArgs, fetchMartRows, writeRowsToSqlite diff --git a/scripts/map_dev_server.js b/scripts/map_dev_server.js index 87f8cc2..111a139 100644 --- a/scripts/map_dev_server.js +++ b/scripts/map_dev_server.js @@ -4,6 +4,7 @@ const http = require('node:http'); const { DatabaseSync } = require('node:sqlite'); const { + ValidationError, parseServerArgs, parseSupplyPointFilters, buildSupplyPointsQuery, @@ -38,7 +39,8 @@ function createApiHandler(database) { response.writeHead(200, { 'content-type': 'application/geo+json; charset=utf-8' }); response.end(request.method === 'HEAD' ? '' : payload); } catch (error) { - response.writeHead(400, { 'content-type': 'application/json; charset=utf-8' }); + const status = error instanceof ValidationError ? 400 : 500; + response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }); response.end(request.method === 'HEAD' ? '' : JSON.stringify({ error: error?.message || String(error) })); } }; @@ -77,7 +79,14 @@ function createServer(database) { const handleSupplyPoints = createApiHandler(database); return http.createServer((request, response) => { - const requestUrl = new URL(request.url, `http://${request.headers.host || 'localhost'}`); + let requestUrl; + try { + requestUrl = new URL(request.url, 'http://localhost'); + } catch (error) { + response.writeHead(400, { 'content-type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify({ error: error?.message || 'invalid request url' })); + return; + } if ((request.method === 'GET' || request.method === 'HEAD') && requestUrl.pathname === '/api/supply-points') { handleSupplyPoints(request, requestUrl, response); diff --git a/tests/map_data.test.js b/tests/map_data.test.js index 7d616fa..347de6b 100644 --- a/tests/map_data.test.js +++ b/tests/map_data.test.js @@ -12,7 +12,9 @@ const { parseExportArgs, parseServerArgs, parseSupplyPointFilters, - toFeatureCollection + toFeatureCollection, + validateProjectId, + ValidationError } = require('../lib/map_data'); test('Map Data: export 用CLI引数を正常に解釈できる', () => { @@ -66,8 +68,12 @@ test('Map Data: BigQuery SELECT SQL に null 除外が含まれる', () => { assert.match(sql, /FROM `rideoasis-dev\.rideoasis_mart\.rideoasis_supply_points`/); }); +test('Map Data: 不正な project id は例外を投げる', () => { + assert.throws(() => validateProjectId('bad.project'), ValidationError); +}); + test('Map Data: export 用 bq query は 100 件上限を外す', () => { - const { buildBqArgs } = require('../scripts/export_map_db'); + const { DEFAULT_BQ_TIMEOUT_MS, buildBqArgs } = require('../scripts/export_map_db'); const args = buildBqArgs({ project: 'rideoasis-dev', dataset: 'rideoasis_mart', @@ -75,6 +81,7 @@ test('Map Data: export 用 bq query は 100 件上限を外す', () => { location: null }); assert.ok(args.includes('--max_rows=1000000000')); + assert.equal(DEFAULT_BQ_TIMEOUT_MS, 10 * 60 * 1000); }); test('Map Data: null 座標の行は normalize 時に除外される', () => { diff --git a/tests/route_math.test.js b/tests/route_math.test.js index 779cc5d..c83c127 100644 --- a/tests/route_math.test.js +++ b/tests/route_math.test.js @@ -18,6 +18,20 @@ test('Route Math: GPX から trkpt を順序通り抽出できる', () => { ]); }); +test('Route Math: 属性順やシングルクォートが異なる GPX も抽出できる', () => { + const coords = parseCoordinateTokens([ + "", + "", + "", + '' + ].join('')); + + assert.deepEqual(coords, [ + [139.2, 35.2], + [139.3, 35.3] + ]); +}); + test('Route Math: 2点未満の GPX は例外を投げる', () => { assert.throws(() => parseGpxText(''), /2点以上/); }); From 071df667dafc43b6edc40ce74bd1e22790225674 Mon Sep 17 00:00:00 2001 From: ochanuco Date: Fri, 20 Mar 2026 00:18:31 +0900 Subject: [PATCH 3/4] =?UTF-8?q?docs:=20=E4=B8=BB=E8=A6=81=E9=96=A2?= =?UTF-8?q?=E6=95=B0=E3=81=ABJSDoc=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app.js | 19 +++++++++++++++++++ frontend/gpx.js | 2 ++ frontend/route_math.js | 8 ++++++++ lib/map_data.js | 17 +++++++++++++++++ scripts/export_map_db.js | 6 ++++++ scripts/map_dev_server.js | 6 ++++++ 6 files changed, 58 insertions(+) diff --git a/frontend/app.js b/frontend/app.js index 5a2eb5f..1b2776a 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -91,16 +91,19 @@ let routeCoordinates = []; let matchedPoints = []; let activeSupplyPointId = null; +/** Updates the top-right status badge. */ function setStatus(message) { elements.status.textContent = message; } +/** Returns the currently enabled chain filters from the UI. */ function selectedChains() { return Array.from(document.querySelectorAll('.chains input[type="checkbox"]:checked')).map( (input) => input.value ); } +/** Renders the matched supply point list beside the map. */ function buildPointList(points) { elements.pointList.innerHTML = ''; if (points.length === 0) { @@ -129,6 +132,7 @@ function buildPointList(points) { } } +/** Escapes text before inserting it into HTML fragments. */ function escapeHtml(value) { return String(value) .replaceAll('&', '&') @@ -138,6 +142,7 @@ function escapeHtml(value) { .replaceAll("'", '''); } +/** Allows only http/https URLs for outbound source links shown in the popup. */ function safeExternalUrl(value) { if (!value) return null; try { @@ -151,6 +156,7 @@ function safeExternalUrl(value) { } } +/** Builds the popup HTML for a selected supply point. */ function buildPopupHtml(props) { const safeUrl = safeExternalUrl(props.source_url); const link = safeUrl @@ -167,6 +173,7 @@ function buildPopupHtml(props) { ].join(''); } +/** Marks one supply point active and opens its popup. */ function activatePoint(supplyPointId) { activeSupplyPointId = supplyPointId; for (const feature of pointSource.getFeatures()) { @@ -181,6 +188,7 @@ function activatePoint(supplyPointId) { buildPointList(matchedPoints); } +/** Clears popup and active marker state. */ function clearPopup() { activeSupplyPointId = null; elements.popup.hidden = true; @@ -191,12 +199,14 @@ function clearPopup() { buildPointList(matchedPoints); } +/** Updates summary cards for route points, candidates, and matches. */ function updateSummary(candidateCount, matchedCount) { elements.routePointCount.textContent = routeCoordinates.length ? String(routeCoordinates.length) : '-'; elements.candidateCount.textContent = String(candidateCount); elements.matchedCount.textContent = String(matchedCount); } +/** Renders the uploaded route and its start/end markers. */ function renderRoute(feature) { routeSource.clear(); endpointSource.clear(); @@ -216,6 +226,7 @@ function renderRoute(feature) { } } +/** Converts matched GeoJSON points into OpenLayers features. */ function renderMatchedPoints(points) { pointSource.clear(); const features = points.map((feature) => { @@ -229,6 +240,7 @@ function renderMatchedPoints(points) { pointSource.addFeatures(features); } +/** Fits the map view to the currently visible route and points. */ function fitToVisibleData() { const extent = ol.extent.createEmpty(); let hasData = false; @@ -244,17 +256,20 @@ function fitToVisibleData() { } } +/** Parses GPX text and converts it into an OpenLayers route feature. */ function createRouteFeatureFromGpx(gpxText) { const parsed = window.GpxParser.parseGpxText(gpxText); routeCoordinates = parsed.geometry.coordinates; return routeGeoJsonFormat.readFeature(parsed); } +/** Expands the route bbox so the API can return nearby candidate points. */ function expandedBboxForQuery(distanceMeters) { const routeBbox = window.RouteMath.computeBbox(routeCoordinates); return window.RouteMath.expandBbox(routeBbox, Math.max(distanceMeters, 2000)); } +/** Loads candidate supply points from the local API for the current filters. */ async function fetchCandidatePoints(distanceMeters) { const chains = selectedChains(); const minPointLevel = Number(elements.minPointLevel.value) || 8; @@ -276,6 +291,7 @@ async function fetchCandidatePoints(distanceMeters) { return response.json(); } +/** Applies the browser-side point-to-route distance filter. */ function filterMatchedPoints(featureCollection, distanceMeters) { return featureCollection.features .map((feature) => { @@ -295,6 +311,7 @@ function filterMatchedPoints(featureCollection, distanceMeters) { .sort((a, b) => a.properties.route_distance_m - b.properties.route_distance_m); } +/** Refreshes API candidates and matched points for the loaded route. */ async function refreshMap() { if (!routeCoordinates.length) { setStatus('先に GPX を読み込んでください'); @@ -319,6 +336,7 @@ async function refreshMap() { } } +/** Reads the selected GPX file and triggers the initial map render. */ async function handleGpxFile(event) { const file = event.target.files?.[0]; if (!file) return; @@ -336,6 +354,7 @@ async function handleGpxFile(event) { } } +/** Wires DOM and map click events for the static frontend. */ function bindEvents() { elements.gpxFile.addEventListener('change', handleGpxFile); elements.refresh.addEventListener('click', refreshMap); diff --git a/frontend/gpx.js b/frontend/gpx.js index b5e76d9..9307030 100644 --- a/frontend/gpx.js +++ b/frontend/gpx.js @@ -8,6 +8,7 @@ const POINT_TAG_RE = /<(trkpt|rtept)\b([^>]*)>/gi; const ATTR_RE = /\b(lat|lon)\s*=\s*(['"])(.*?)\2/gi; + /** Extracts ordered [lng, lat] coordinates from GPX track or route point tags. */ function parseCoordinateTokens(gpxText) { const coords = []; let tagMatch; @@ -26,6 +27,7 @@ return coords; } + /** Parses GPX text into a GeoJSON LineString feature for the route viewer. */ function parseGpxText(gpxText) { const coordinates = parseCoordinateTokens(String(gpxText || '')); if (coordinates.length < 2) { diff --git a/frontend/route_math.js b/frontend/route_math.js index 7e520d1..f73f8b1 100644 --- a/frontend/route_math.js +++ b/frontend/route_math.js @@ -5,15 +5,18 @@ } root.RouteMath = factory(); })(typeof globalThis !== 'undefined' ? globalThis : this, function () { + /** Converts decimal degrees to radians. */ function toRadians(value) { return (value * Math.PI) / 180; } + /** Computes the mean latitude used for a lightweight local projection. */ function meanLatitude(coords) { if (!coords.length) return 0; return coords.reduce((sum, coord) => sum + coord[1], 0) / coords.length; } + /** Projects lon/lat coordinates to approximate planar meters near the route. */ function projectLonLatToMeters(coord, referenceLat) { const lng = coord[0]; const lat = coord[1]; @@ -23,6 +26,7 @@ return [x, y]; } + /** Computes the minimum distance from a point to a single route segment. */ function distancePointToSegmentMeters(point, start, end, referenceLat) { const p = projectLonLatToMeters(point, referenceLat); const a = projectLonLatToMeters(start, referenceLat); @@ -43,6 +47,7 @@ return Math.hypot(p[0] - closestX, p[1] - closestY); } + /** Computes the minimum point-to-route distance in meters. */ function pointToRouteDistanceMeters(point, coordinates) { if (!Array.isArray(coordinates) || coordinates.length < 2) { return Number.POSITIVE_INFINITY; @@ -63,6 +68,7 @@ return minDistance; } + /** Converts a meter padding value to degree deltas around a latitude. */ function metersToDegreePadding(latitude, meters) { const latPadding = meters / 111320; const cosLat = Math.cos(toRadians(latitude)); @@ -70,6 +76,7 @@ return { latPadding, lngPadding }; } + /** Computes a [minLng, minLat, maxLng, maxLat] bbox from route coordinates. */ function computeBbox(coordinates) { if (!coordinates.length) return null; let minLng = coordinates[0][0]; @@ -87,6 +94,7 @@ return [minLng, minLat, maxLng, maxLat]; } + /** Expands a bbox by a meter padding converted around the route center latitude. */ function expandBbox(bbox, meters) { if (!bbox) return null; const [, minLat, , maxLat] = bbox; diff --git a/lib/map_data.js b/lib/map_data.js index 6d3d91b..69be16e 100644 --- a/lib/map_data.js +++ b/lib/map_data.js @@ -26,6 +26,7 @@ const SQLITE_COLUMNS = [ 'updated_at' ]; +/** Validates the SQLite output path argument. */ function sanitizeSqlitePath(value) { if (!value) { throw new ValidationError('sqlite path is required'); @@ -33,6 +34,7 @@ function sanitizeSqlitePath(value) { return String(value); } +/** Reads a required CLI flag value. */ function readValue(argv, index, flag) { const value = argv[index + 1]; if (!value || String(value).startsWith('-')) { @@ -41,6 +43,7 @@ function readValue(argv, index, flag) { return value; } +/** Parses CLI arguments for the BigQuery-to-SQLite export script. */ function parseExportArgs(argv = process.argv) { const args = { project: null, @@ -93,6 +96,7 @@ function parseExportArgs(argv = process.argv) { return { help: false, ...args }; } +/** Parses CLI arguments for the local map development server. */ function parseServerArgs(argv = process.argv) { const args = { db: DEFAULT_SQLITE_PATH, @@ -124,6 +128,7 @@ function parseServerArgs(argv = process.argv) { return { help: false, ...args }; } +/** Validates SQL identifier fragments used for dataset, table, and chain names. */ function sanitizeId(value, label) { if (!/^[A-Za-z0-9_]+$/.test(value)) { throw new ValidationError(`invalid ${label}: ${value}`); @@ -131,6 +136,7 @@ function sanitizeId(value, label) { return value; } +/** Validates a BigQuery project id before interpolating it into SQL. */ function validateProjectId(value) { const projectId = String(value || ''); if (!/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(projectId)) { @@ -139,6 +145,7 @@ function validateProjectId(value) { return projectId; } +/** Builds the BigQuery SQL used to export the final supply point mart. */ function buildBqSelectSql(project, dataset, table) { const validatedProject = validateProjectId(project); const safeDataset = sanitizeId(dataset, 'dataset'); @@ -162,6 +169,7 @@ function buildBqSelectSql(project, dataset, table) { ].join('\n'); } +/** Returns the SQLite schema and indexes for local map lookups. */ function createSchemaSql() { return [ 'CREATE TABLE IF NOT EXISTS supply_points (', @@ -184,6 +192,7 @@ function createSchemaSql() { ].join('\n'); } +/** Normalizes a mart row into the SQLite insert shape, skipping null coordinates. */ function normalizePointRow(row) { if (!row || row.lat == null || row.lng == null) { return null; @@ -204,6 +213,7 @@ function normalizePointRow(row) { }; } +/** Prepares the SQLite upsert statement used during export. */ function createUpsertStatement(database) { return database.prepare(` INSERT INTO supply_points ( @@ -245,6 +255,7 @@ function createUpsertStatement(database) { `); } +/** Parses a bbox query parameter into numeric bounds. */ function parseBBox(value) { if (!value) return null; const parts = String(value).split(',').map((v) => Number(v.trim())); @@ -258,6 +269,7 @@ function parseBBox(value) { return { minLng, minLat, maxLng, maxLat }; } +/** Parses the comma-separated chain filter query parameter. */ function parseChains(value) { if (!value) return []; return String(value) @@ -267,6 +279,7 @@ function parseChains(value) { .map((item) => sanitizeId(item, 'chain')); } +/** Parses a positive integer query parameter with a fallback default. */ function parsePositiveInt(value, label, fallback) { if (value == null || value === '') return fallback; const parsed = Number(value); @@ -276,6 +289,7 @@ function parsePositiveInt(value, label, fallback) { return parsed; } +/** Parses HTTP query parameters for the supply point lookup API. */ function parseSupplyPointFilters(searchParams) { return { bbox: parseBBox(searchParams.get('bbox')), @@ -289,6 +303,7 @@ function parseSupplyPointFilters(searchParams) { }; } +/** Builds the SQLite SELECT and bound params for supply point API queries. */ function buildSupplyPointsQuery(filters) { const where = []; const params = {}; @@ -332,6 +347,7 @@ function buildSupplyPointsQuery(filters) { }; } +/** Converts a SQLite row into a GeoJSON Point feature. */ function toGeoJsonFeature(row) { return { type: 'Feature', @@ -353,6 +369,7 @@ function toGeoJsonFeature(row) { }; } +/** Wraps point features into a GeoJSON FeatureCollection payload. */ function toFeatureCollection(rows) { return { type: 'FeatureCollection', diff --git a/scripts/export_map_db.js b/scripts/export_map_db.js index b7cc58d..6e03484 100644 --- a/scripts/export_map_db.js +++ b/scripts/export_map_db.js @@ -14,6 +14,7 @@ const { const DEFAULT_BQ_TIMEOUT_MS = 10 * 60 * 1000; +/** Prints usage for the local BigQuery-to-SQLite export command. */ function printHelp() { console.log([ 'Usage:', @@ -24,6 +25,7 @@ function printHelp() { ].join('\n')); } +/** Builds the bq CLI arguments for exporting mart rows as JSON. */ function buildBqArgs(options) { const args = ['--project_id', options.project]; if (options.location) { @@ -34,6 +36,7 @@ function buildBqArgs(options) { return args; } +/** Fetches mart rows from BigQuery via the bq CLI. */ function fetchMartRows(options) { const args = buildBqArgs(options); const output = execFileSync('bq', args, { @@ -44,10 +47,12 @@ function fetchMartRows(options) { return JSON.parse(output); } +/** Ensures the SQLite output directory exists. */ function ensureParentDirectory(filePath) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); } +/** Replaces the local SQLite table contents with the exported mart rows. */ function writeRowsToSqlite(rows, outputPath) { ensureParentDirectory(outputPath); const database = new DatabaseSync(outputPath); @@ -76,6 +81,7 @@ function writeRowsToSqlite(rows, outputPath) { return count; } +/** Runs the local export CLI entrypoint. */ function main() { const args = parseExportArgs(process.argv); if (args.help) { diff --git a/scripts/map_dev_server.js b/scripts/map_dev_server.js index 111a139..aee12f5 100644 --- a/scripts/map_dev_server.js +++ b/scripts/map_dev_server.js @@ -20,6 +20,7 @@ const MIME_TYPES = { '.gpx': 'application/gpx+xml; charset=utf-8' }; +/** Prints usage for the local static frontend and API server. */ function printHelp() { console.log([ 'Usage:', @@ -29,6 +30,7 @@ function printHelp() { ].join('\n')); } +/** Creates the supply point API handler backed by the local SQLite database. */ function createApiHandler(database) { return function handleSupplyPoints(request, requestUrl, response) { try { @@ -46,6 +48,7 @@ function createApiHandler(database) { }; } +/** Resolves a request pathname to a file under frontend/. */ function resolveStaticPath(requestPathname) { const relative = requestPathname === '/' ? '/index.html' : requestPathname; const normalized = path @@ -55,6 +58,7 @@ function resolveStaticPath(requestPathname) { return path.join(FRONTEND_DIR, normalized); } +/** Serves a static frontend asset from frontend/. */ function serveStaticFile(requestPathname, response) { const filePath = resolveStaticPath(requestPathname); if (!filePath.startsWith(FRONTEND_DIR)) { @@ -75,6 +79,7 @@ function serveStaticFile(requestPathname, response) { return fs.createReadStream(filePath).pipe(response); } +/** Creates the local HTTP server for static assets and API requests. */ function createServer(database) { const handleSupplyPoints = createApiHandler(database); @@ -117,6 +122,7 @@ function createServer(database) { }); } +/** Runs the local map development server CLI entrypoint. */ function main() { const args = parseServerArgs(process.argv); if (args.help) { From fdebaa3e861331388ed66a922fa8235ce202c4ea Mon Sep 17 00:00:00 2001 From: ochanuco Date: Fri, 20 Mar 2026 00:37:08 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20API=E4=B8=8A=E9=99=90=E3=81=A8?= =?UTF-8?q?=E3=83=9A=E3=83=BC=E3=82=B8=E3=83=B3=E3=82=B0=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app.js | 48 ++++++++++++++++++++++++++++----------- lib/map_data.js | 33 +++++++++++++++++++++++---- scripts/map_dev_server.js | 17 ++++++++++++-- tests/map_data.test.js | 32 +++++++++++++++++++++++++- 4 files changed, 110 insertions(+), 20 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index 1b2776a..15f5583 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -90,6 +90,7 @@ let routeFeature = null; let routeCoordinates = []; let matchedPoints = []; let activeSupplyPointId = null; +const API_PAGE_LIMIT = 10000; /** Updates the top-right status badge. */ function setStatus(message) { @@ -274,21 +275,42 @@ async function fetchCandidatePoints(distanceMeters) { const chains = selectedChains(); const minPointLevel = Number(elements.minPointLevel.value) || 8; const bbox = expandedBboxForQuery(distanceMeters); - const params = new URLSearchParams(); - if (bbox) { - params.set('bbox', bbox.join(',')); - } - if (chains.length > 0) { - params.set('chains', chains.join(',')); + const features = []; + const seenIds = new Set(); + let offset = 0; + + while (true) { + const params = new URLSearchParams(); + if (bbox) { + params.set('bbox', bbox.join(',')); + } + params.set('chains', chains.length > 0 ? chains.join(',') : ''); + params.set('min_point_level', String(minPointLevel)); + params.set('limit', String(API_PAGE_LIMIT)); + params.set('offset', String(offset)); + + const response = await fetch(`${API_BASE}/supply-points?${params.toString()}`); + if (!response.ok) { + throw new Error(`API error (${response.status})`); + } + const page = await response.json(); + for (const feature of page.features) { + const id = feature.properties?.supply_point_id; + if (id && !seenIds.has(id)) { + seenIds.add(id); + features.push(feature); + } + } + if (page.features.length < API_PAGE_LIMIT) { + break; + } + offset += API_PAGE_LIMIT; } - params.set('min_point_level', String(minPointLevel)); - params.set('limit', '10000'); - const response = await fetch(`${API_BASE}/supply-points?${params.toString()}`); - if (!response.ok) { - throw new Error(`API error (${response.status})`); - } - return response.json(); + return { + type: 'FeatureCollection', + features + }; } /** Applies the browser-side point-to-route distance filter. */ diff --git a/lib/map_data.js b/lib/map_data.js index 69be16e..0dfa12a 100644 --- a/lib/map_data.js +++ b/lib/map_data.js @@ -4,6 +4,7 @@ const DEFAULT_SQLITE_PATH = '.local/rideoasis-map.db'; const DEFAULT_API_PORT = 8787; const DEFAULT_MIN_POINT_LEVEL = 8; const DEFAULT_LIMIT = 5000; +const MAX_LIMIT = 10000; class ValidationError extends Error { constructor(message) { @@ -271,7 +272,8 @@ function parseBBox(value) { /** Parses the comma-separated chain filter query parameter. */ function parseChains(value) { - if (!value) return []; + if (value == null) return null; + if (String(value).trim() === '') return []; return String(value) .split(',') .map((item) => item.trim()) @@ -289,8 +291,22 @@ function parsePositiveInt(value, label, fallback) { return parsed; } +/** Parses a non-negative integer query parameter with a fallback default. */ +function parseNonNegativeInt(value, label, fallback) { + if (value == null || value === '') return fallback; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new ValidationError(`${label} must be a non-negative integer`); + } + return parsed; +} + /** Parses HTTP query parameters for the supply point lookup API. */ function parseSupplyPointFilters(searchParams) { + const limit = parsePositiveInt(searchParams.get('limit'), 'limit', DEFAULT_LIMIT); + if (limit > MAX_LIMIT) { + throw new ValidationError(`limit must be <= ${MAX_LIMIT}`); + } return { bbox: parseBBox(searchParams.get('bbox')), chains: parseChains(searchParams.get('chains')), @@ -299,7 +315,8 @@ function parseSupplyPointFilters(searchParams) { 'min_point_level', DEFAULT_MIN_POINT_LEVEL ), - limit: parsePositiveInt(searchParams.get('limit'), 'limit', DEFAULT_LIMIT) + limit, + offset: parseNonNegativeInt(searchParams.get('offset'), 'offset', 0) }; } @@ -322,7 +339,11 @@ function buildSupplyPointsQuery(filters) { params.minPointLevel = filters.minPointLevel; } - if (filters.chains.length > 0) { + if (filters.chains === null) { + // No explicit chain filter means all chains. + } else if (filters.chains.length === 0) { + where.push('0 = 1'); + } else if (filters.chains.length > 0) { const placeholders = filters.chains.map((_, index) => `:chain${index}`); where.push(`chain IN (${placeholders.join(', ')})`); filters.chains.forEach((chain, index) => { @@ -331,6 +352,7 @@ function buildSupplyPointsQuery(filters) { } params.limit = filters.limit; + params.offset = filters.offset; return { sql: [ @@ -339,7 +361,8 @@ function buildSupplyPointsQuery(filters) { 'FROM supply_points', where.length > 0 ? `WHERE ${where.join(' AND ')}` : '', 'ORDER BY chain, name, supply_point_id', - 'LIMIT :limit' + 'LIMIT :limit', + 'OFFSET :offset' ] .filter(Boolean) .join('\n'), @@ -380,6 +403,7 @@ function toFeatureCollection(rows) { module.exports = { DEFAULT_API_PORT, DEFAULT_LIMIT, + MAX_LIMIT, DEFAULT_MART_DATASET, DEFAULT_MART_TABLE, DEFAULT_MIN_POINT_LEVEL, @@ -393,6 +417,7 @@ module.exports = { parseBBox, parseChains, parseExportArgs, + parseNonNegativeInt, parseServerArgs, parseSupplyPointFilters, sanitizeId, diff --git a/scripts/map_dev_server.js b/scripts/map_dev_server.js index aee12f5..703315f 100644 --- a/scripts/map_dev_server.js +++ b/scripts/map_dev_server.js @@ -75,8 +75,21 @@ function serveStaticFile(requestPathname, response) { const ext = path.extname(filePath); const mimeType = MIME_TYPES[ext] || 'application/octet-stream'; - response.writeHead(200, { 'content-type': mimeType }); - return fs.createReadStream(filePath).pipe(response); + const stream = fs.createReadStream(filePath); + stream.on('open', () => { + response.writeHead(200, { 'content-type': mimeType }); + stream.pipe(response); + }); + stream.on('error', (error) => { + if (response.headersSent) { + response.destroy(error); + return; + } + const status = error?.code === 'ENOENT' ? 404 : 500; + response.writeHead(status); + response.end(status === 404 ? 'not found' : 'internal server error'); + }); + return stream; } /** Creates the local HTTP server for static assets and API requests. */ diff --git a/tests/map_data.test.js b/tests/map_data.test.js index 347de6b..3700f95 100644 --- a/tests/map_data.test.js +++ b/tests/map_data.test.js @@ -7,9 +7,11 @@ const { buildSupplyPointsQuery, createSchemaSql, createUpsertStatement, + MAX_LIMIT, normalizePointRow, parseBBox, parseExportArgs, + parseNonNegativeInt, parseServerArgs, parseSupplyPointFilters, toFeatureCollection, @@ -60,6 +62,32 @@ test('Map Data: supply point filters を既定値付きで解釈できる', () = assert.equal(filters.chains.length, 2); assert.equal(filters.minPointLevel, 8); assert.equal(filters.limit, 5000); + assert.equal(filters.offset, 0); +}); + +test('Map Data: chains が空文字なら明示的な0件指定として扱う', () => { + const filters = parseSupplyPointFilters(new URLSearchParams('chains=')); + assert.deepEqual(filters.chains, []); + const { sql } = buildSupplyPointsQuery({ + bbox: null, + chains: filters.chains, + minPointLevel: 8, + limit: 100, + offset: 0 + }); + assert.match(sql, /0 = 1/); +}); + +test('Map Data: limit の上限を超えると例外を投げる', () => { + assert.throws( + () => parseSupplyPointFilters(new URLSearchParams(`limit=${MAX_LIMIT + 1}`)), + /limit must be <=/ + ); +}); + +test('Map Data: offset は非負整数のみ許可する', () => { + assert.equal(parseNonNegativeInt('0', 'offset', 99), 0); + assert.throws(() => parseNonNegativeInt('-1', 'offset', 0), /non-negative/); }); test('Map Data: BigQuery SELECT SQL に null 除外が含まれる', () => { @@ -137,13 +165,15 @@ test('Map Data: API query が bbox と chain と point_level を反映する', ( bbox: { minLng: 139, minLat: 35, maxLng: 140, maxLat: 36 }, chains: ['lawson', 'familymart'], minPointLevel: 8, - limit: 123 + limit: 123, + offset: 456 }); assert.match(sql, /lng BETWEEN :minLng AND :maxLng/); assert.match(sql, /chain IN \(:chain0, :chain1\)/); assert.match(sql, /geocode_point_level >= :minPointLevel/); assert.equal(params.limit, 123); + assert.equal(params.offset, 456); assert.equal(params.chain0, 'lawson'); });