- 카카오 지도(지도 2/3 + 판매소 목록 1/3, 높이 고정·스크롤), 목록 클릭 시 줌인 - 지오코딩 폴백(정밀→도로명→지번→키워드→행정동)으로 마커 표시 - 메뉴검색: 자동완성 드롭다운 + 기본 "최근 방문 메뉴"(localStorage, 뒤로가기/bfcache 갱신) - 메뉴검색 박스 녹색(#009688), 지도와 높이 일치 - resolveLgLabel: 선택 지자체 실제 이름 사용, '(데모)' 문구 제거 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
195 lines
8.2 KiB
PHP
195 lines
8.2 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
/**
|
|
* 메인 대시보드 카카오 지도 패널 — 지도(좌) + 지정판매소 목록(우, 스크롤).
|
|
* 주소→좌표는 카카오 지오코딩(services)으로 클라이언트 변환. 목록 클릭 시 해당 위치로 이동.
|
|
*
|
|
* @var string $kakaoJsKey
|
|
* @var string $lgLabel
|
|
* @var array<int,array{name:string,addr:string}> $mapShops
|
|
*/
|
|
$kakaoJsKey = (string) ($kakaoJsKey ?? '');
|
|
$lgLabel = (string) ($lgLabel ?? '');
|
|
$mapShops = is_array($mapShops ?? null) ? $mapShops : [];
|
|
$mapId = 'mainKakaoMap';
|
|
?>
|
|
<div class="rounded-xl bg-white border border-gray-200 shadow-sm overflow-hidden">
|
|
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-100">
|
|
<h2 class="text-sm font-bold text-gray-900"><i class="fa-solid fa-map-location-dot text-[#243a5e] mr-1"></i>지정판매소 위치<?= $lgLabel !== '' ? ' · ' . esc($lgLabel) : '' ?> <span class="text-[11px] font-normal text-gray-400">(<?= count($mapShops) ?>곳)</span></h2>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-[1fr_240px]">
|
|
<!-- 지도 -->
|
|
<div id="<?= $mapId ?>" style="width:100%;height:200px;background:#eef2f7;" role="application" aria-label="<?= esc($lgLabel, 'attr') ?> 지정판매소 지도"></div>
|
|
|
|
<!-- 판매소 목록 (스크롤) -->
|
|
<div class="border-t md:border-t-0 md:border-l border-gray-100 overflow-y-auto" style="height:200px;">
|
|
<?php if ($mapShops === []): ?>
|
|
<p class="p-3 text-[12px] text-gray-400">표시할 지정판매소가 없습니다.</p>
|
|
<?php else: ?>
|
|
<ul id="shopList" class="divide-y divide-gray-50">
|
|
<?php foreach ($mapShops as $i => $shop): ?>
|
|
<li>
|
|
<button type="button" data-idx="<?= (int) $i ?>"
|
|
class="shop-item w-full text-left px-3 py-2 hover:bg-blue-50/60 transition flex gap-2 items-start">
|
|
<span class="shop-dot mt-1 inline-block w-2 h-2 rounded-full bg-gray-300 shrink-0"></span>
|
|
<span class="min-w-0">
|
|
<span class="block text-[12px] font-semibold text-gray-800 truncate"><?= esc($shop['name'] !== '' ? $shop['name'] : '(이름없음)') ?></span>
|
|
<span class="block text-[11px] text-gray-500 truncate"><?= esc($shop['addr']) ?></span>
|
|
</span>
|
|
</button>
|
|
</li>
|
|
<?php endforeach; ?>
|
|
</ul>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
|
|
<?php if ($kakaoJsKey === ''): ?>
|
|
<div class="px-4 py-2 text-[11px] text-amber-700 bg-amber-50 border-t border-amber-200">카카오맵 키가 설정되지 않아 지도를 표시할 수 없습니다.</div>
|
|
<?php endif; ?>
|
|
</div>
|
|
|
|
<?php if ($kakaoJsKey !== ''): ?>
|
|
<script>
|
|
(function () {
|
|
var APP_KEY = <?= json_encode($kakaoJsKey, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
|
var SHOPS = <?= json_encode($mapShops, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS) ?>;
|
|
var MAP_ID = <?= json_encode($mapId, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
|
var DEFAULT_CENTER = { lat: 35.8714, lng: 128.6014 };
|
|
|
|
var markers = {}; // idx -> kakao.maps.Marker
|
|
var positions = {}; // idx -> kakao.maps.LatLng
|
|
var mapRef = null, infowindow = null, geocoder = null, places = null;
|
|
|
|
function ensureScript(cb) {
|
|
if (typeof kakao !== 'undefined' && kakao.maps && kakao.maps.services) { cb(); return; }
|
|
var s = document.createElement('script');
|
|
s.charset = 'UTF-8'; s.async = true;
|
|
s.src = 'https://dapi.kakao.com/v2/maps/sdk.js?appkey=' + encodeURIComponent(APP_KEY) + '&libraries=services&autoload=false';
|
|
s.onload = function () {
|
|
if (typeof kakao === 'undefined' || !kakao.maps || typeof kakao.maps.load !== 'function') { return; }
|
|
kakao.maps.load(cb);
|
|
};
|
|
document.head.appendChild(s);
|
|
}
|
|
|
|
// "대구광역시 북구 검단동 칠곡중앙대로 21" → "대구광역시 북구 칠곡중앙대로 21" (행정동 토큰 제거)
|
|
function roadVariant(addr) {
|
|
var p = String(addr || '').trim().split(/\s+/);
|
|
if (p.length < 4) return '';
|
|
var roadIdx = -1;
|
|
for (var i = 2; i < p.length; i++) { if (/(로|길)$/.test(p[i])) { roadIdx = i; break; } }
|
|
if (roadIdx < 3) return '';
|
|
var out = [];
|
|
p.forEach(function (tok, i) {
|
|
if (i >= 2 && i < roadIdx && /(동|가|리)$/.test(tok)) return; // 행정동 제거
|
|
out.push(tok);
|
|
});
|
|
var joined = out.join(' ');
|
|
return joined !== addr ? joined : '';
|
|
}
|
|
|
|
// "대구광역시 북구 검단동 ..." → "대구광역시 북구 검단동" (시도 구군 동 — 행정구역 폴백)
|
|
function regionVariant(addr) {
|
|
var p = String(addr || '').trim().split(/\s+/);
|
|
if (p.length < 3) return '';
|
|
for (var i = 2; i < p.length; i++) {
|
|
if (/(동|가|리)$/.test(p[i])) { return p[0] + ' ' + p[1] + ' ' + p[i]; }
|
|
}
|
|
return p[0] + ' ' + p[1]; // 동을 못 찾으면 구·군까지
|
|
}
|
|
|
|
// 여러 후보를 순서대로 시도(정밀주소 → 도로명변형 → 지번 → 키워드 → 행정동 → 행정동 키워드)
|
|
function geocodeChain(shop, cb) {
|
|
var tries = [];
|
|
if (shop.addr) tries.push(['addr', shop.addr]);
|
|
var rv = roadVariant(shop.addr);
|
|
if (rv) tries.push(['addr', rv]);
|
|
if (shop.jibun) tries.push(['addr', shop.jibun]);
|
|
if (rv) tries.push(['kw', rv]);
|
|
else if (shop.addr) tries.push(['kw', shop.addr]);
|
|
var region = regionVariant(shop.addr || shop.jibun);
|
|
if (region) { tries.push(['addr', region]); tries.push(['kw', region]); }
|
|
|
|
(function next(i) {
|
|
if (i >= tries.length) { cb(null); return; }
|
|
var mode = tries[i][0], q = tries[i][1];
|
|
if (mode === 'addr') {
|
|
geocoder.addressSearch(q, function (result, status) {
|
|
if (status === kakao.maps.services.Status.OK && result && result[0]) {
|
|
cb(new kakao.maps.LatLng(result[0].y, result[0].x));
|
|
} else { next(i + 1); }
|
|
});
|
|
} else {
|
|
places.keywordSearch(q, function (data, status) {
|
|
if (status === kakao.maps.services.Status.OK && data && data[0]) {
|
|
cb(new kakao.maps.LatLng(data[0].y, data[0].x));
|
|
} else { next(i + 1); }
|
|
});
|
|
}
|
|
})(0);
|
|
}
|
|
|
|
function openInfo(idx) {
|
|
var name = (SHOPS[idx] && SHOPS[idx].name) || '판매소';
|
|
infowindow.setContent('<div style="padding:5px 8px;font-size:12px;font-weight:600;white-space:nowrap;">' + name + '</div>');
|
|
if (markers[idx]) infowindow.open(mapRef, markers[idx]);
|
|
}
|
|
|
|
function focusShop(idx) {
|
|
var pos = positions[idx];
|
|
if (!pos || !mapRef) return;
|
|
mapRef.setCenter(pos);
|
|
mapRef.setLevel(3); // 줌인
|
|
openInfo(idx);
|
|
}
|
|
|
|
function initMap() {
|
|
var el = document.getElementById(MAP_ID);
|
|
if (!el || typeof kakao === 'undefined' || !kakao.maps) return;
|
|
var map = new kakao.maps.Map(el, { center: new kakao.maps.LatLng(DEFAULT_CENTER.lat, DEFAULT_CENTER.lng), level: 8 });
|
|
mapRef = map;
|
|
infowindow = new kakao.maps.InfoWindow({ zIndex: 1 });
|
|
geocoder = new kakao.maps.services.Geocoder();
|
|
places = new kakao.maps.services.Places();
|
|
var bounds = new kakao.maps.LatLngBounds();
|
|
var placed = 0, pending = SHOPS.length;
|
|
|
|
function done() {
|
|
if (placed > 0) map.setBounds(bounds);
|
|
setTimeout(function () { map.relayout(); if (placed > 0) map.setBounds(bounds); }, 150);
|
|
}
|
|
if (pending === 0) { done(); return; }
|
|
|
|
SHOPS.forEach(function (shop, idx) {
|
|
geocodeChain(shop, function (pos) {
|
|
pending--;
|
|
if (pos) {
|
|
var marker = new kakao.maps.Marker({ position: pos, map: map });
|
|
markers[idx] = marker;
|
|
positions[idx] = pos;
|
|
bounds.extend(pos); placed++;
|
|
kakao.maps.event.addListener(marker, 'click', function () { openInfo(idx); });
|
|
var dot = document.querySelector('.shop-item[data-idx="' + idx + '"] .shop-dot');
|
|
if (dot) { dot.style.background = '#243a5e'; }
|
|
}
|
|
if (pending === 0) done();
|
|
});
|
|
});
|
|
|
|
// 목록 클릭 → 해당 판매소로 줌인
|
|
document.querySelectorAll('.shop-item').forEach(function (btn) {
|
|
btn.addEventListener('click', function () { focusShop(parseInt(btn.getAttribute('data-idx'), 10)); });
|
|
});
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', function () { ensureScript(initMap); });
|
|
} else {
|
|
ensureScript(initMap);
|
|
}
|
|
})();
|
|
</script>
|
|
<?php endif; ?>
|