สร้าง Interactive Map ด้วย Next.js และ MapLibre GL
เรียนรู้วิธีสร้างแผนที่ประเทศไทยแบบโต้ตอบได้ด้วย Next.js และ MapLibre GL พร้อมการแสดงผลข้อมูลแบบ dynamic และฟังก์ชันคลิกเลือกพื้นที่
🚀 ขอฝากครับ GLM Coding AI ช่วยเขียนโค้ดในราคาย่อมเยาว์ — เริ่มต้นเพียง $3/เดือน สมัครสมาชิกตอนนี้และรับข้อเสนอแบบจำกัดเวลา! Link: https://z.ai/subscribe?ic=3SSIIKOOLV ↗
GLM Coding

คลิปใช้ GLM Coding ช่วยเขียนโค้ดสร้างแผนที่ประเทศไทยแบบ Interactive
สร้างแผนที่แสดงผลการเลือกตั้ง (Election Results Map) ด้วย Next.js และ MapLibre GL บทความนี้จะพาสร้างแผนที่จังหวัดของประเทศไทย 77 จังหวัด ที่แสดงผลการเลือกตั้งแบบ real-time พร้อมฟีเจอร์คลิกเลือกจังหวัดและดูรายละเอียด
ตัวอย่างหน้าจอ

MapLibre GL#
MapLibre GL เป็น open-source fork ของ Mapbox GL ที่ให้พลังในการสร้างแผนที่โดยไม่มีข้อจำกัดด้าน license เหมาะสำหรับ:
- Data Visualization แบบโต้ตอบได้ - แสดงข้อมูลการเลือกตั้งแบบ real-time
- Election Maps - แผนที่แสดงผลคะแนนเสียงตามพื้นที่
- Choropleth Maps - แผนที่แสดงความหนาแน่นของข้อมูลตามพื้นที่
- Location-based Applications - แอปพลิเคชันที่ใช้ตำแหน่งที่ตั้ง
- Real-time Tracking Dashboards - หน้าจอติดตามข้อมูลแบบเรียลไทม์
ข้อดีหลักของ MapLibre GL#
| ฟีเจอร์ | ข้อดี |
|---|---|
| ฟรี & Open Source | ไม่ต้องใช้ API Key ใดๆ ใช้ลิขสิทธิ์ BSD-3-Clause สามารถใช้เชิงพาณิชย์ได้ฟรี |
| React Integration | ทำงานร่วมกับ react-map-gl ได้อย่างราบรื่น |
| ประสิทธิภาพสูง | ใช้ GPU ในการ rendering ทำให้แสดงผลได้ลื่นไหล |
| ปรับแต่งได้ง่าย | ควบคุม layers, สี และ interaction ได้อย่างสมบูรณ์ |
| Feature State | สามารถ track hover/selection states ได้แบบ real-time |
ความรู้พื้นฐานที่ต้องมี#
ก่อนเริ่มบทความนี้ ควรมีความรู้พื้นฐานดังนี้:
- Node.js 20+ ติดตั้งไว้ในเครื่องแล้ว
- React พื้นฐาน รู้จัก components, props, state, hooks
- TypeScript พื้นฐาน เข้าใจ interfaces, types, unions
- Tailwind CSS พื้นฐาน การใช้ utility classes, dark mode
- Next.js พื้นฐาน รู้จัก App Router, dynamic imports
แนะนำให้ศึกษา:
- React Documentation ↗ - ศึกษาพื้นฐาน React
- TypeScript Handbook ↗ - ศึกษาพื้นฐาน TypeScript
- Tailwind CSS Docs ↗ - ศึกษาการใช้ Tailwind
- Next.js Documentation ↗ - ศึกษา Next.js App Router
ขั้นตอนที่ 1: สร้าง Next.js Project#
เริ่มต้นด้วยการสร้างโปรเจกต์ Next.js ใหม่พร้อม TypeScript และ Tailwind CSS:
# สร้างโปรเจกต์ Next.js
npx create-next-app@15.5.11 thailand-map -- --typescript --tailwind --eslint
# เข้าไปในโฟลเดอร์โปรเจกต์
cd thailand-mapbashคำอธิบายคำสั่ง:#
npx- เครื่องมือของ npm สำหรับรัน package โดยไม่ต้อง installcreate-next-app@15.5.11- ใช้ version 15.5.11 เพื่อความเสถียร (ระบุ version เพื่อความแน่นอน)--typescript- เปิดใช้งาน TypeScript--tailwind- เปิดใช้งาน Tailwind CSS--eslint- เปิดใช้งาน ESLint สำหรับตรวจสอบ code
ตอบคำถามขณะติดตั้ง:#
คำถามที่จะถูกถาม:
- TypeScript:
Yes- ใช้ TypeScript เพื่อ type safety - ESLint:
Yes- ใช้ ESLint ช่วยตรวจสอบ code - Tailwind CSS:
Yes- ใช้ Tailwind CSS สำหรับ styling src/directory:No- ไม่ใช้ src directory เพื่อความเรียบง่าย- App Router:
Yes- ใช้ App Router ของ Next.js (รุ่นใหม่) - Import alias:
@/*- ตั้งค่า import alias สำหรับความสะดวก
โครงสร้างโปรเจกต์หลังสร้างเสร็จ:#
thailand-map/
├── app/
│ ├── layout.tsx # Root layout
│ ├── page.tsx # Home page
│ └── globals.css # Global styles
├── components/ # React components
├── lib/ # Utility functions
├── public/ # Static files
├── package.json
├── tsconfig.json
└── next.config.ts # Next.js configplaintextตั้งค่า Package.json Scripts#
ตรวจสอบ package.json ว่ามี scripts ดังนี้:
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
}
}jsonหมายเหตุ: อย่าใช้ --turbopack flag ใน scripts เพราะอาจทำให้เกิดปัญหาความเข้ากันได้กับ MapLibre GL ในบางกรณี
ขั้นตอนที่ 2: ติดตั้ง Map Libraries#
ติดตั้ง MapLibre GL และ React wrapper:
# ติดตั้ง map libraries
npm install maplibre-gl@^5.17.0 react-map-gl@^8.1.0
# ติดตั้ง TypeScript types สำหรับ GeoJSON
npm install @types/geojson@^7946.0.16
# ติดตั้ง TypeScript types สำหรับ map
npm install --save-dev @types/maplibre-glbashอธิบายแต่ละ package:#
| Package | วัตถุประสงค์ |
|---|---|
| maplibre-gl | Core library สำหรับแสดงแผนที่ (JavaScript) |
| react-map-gl | React wrapper ที่ทำให้ใช้ MapLibre GL ใน React ได้ง่ายขึ้น |
| @types/geojson | TypeScript definitions สำหรับ GeoJSON format |
| @types/maplibre-gl | TypeScript definitions สำหรับ maplibre-gl |
เวอร์ชันที่ระบุ:#
maplibre-gl@^5.17.0- ระบุเวอร์ชันเพื่อความเสถียร (caret ^ หมายถึง 5.17.x หรือสูงกว่า แต่ไม่เกิน 6.0.0)react-map-gl@^8.1.0- React wrapper version ที่เข้ากันได้กับ maplibre-gl
ตั้งค่า Next.js config สำหรับ MapLibre GL#
อัปเดต next.config.ts เพื่อให้ MapLibre GL ทำงานได้ถูกต้อง:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ['maplibre-gl'],
webpack: (config) => {
config.module = config.module || {};
config.module.unknownContextCritical = false;
return config;
},
};
export default nextConfig;tsคำอธิบาย:
transpilePackages: ['maplibre-gl']- สั่งให้ Next.js transpile maplibre-gl เพื่อให้ทำงานได้กับ TypeScriptwebpack.config- ตั้งค่า webpack ให้ไม่แสดง warning เกี่ยวกับ unknown context
ขั้นตอนที่ 3: ดาวน์โหลดข้อมูล GeoJSON#
GeoJSON คือรูปแบบข้อมูล geographic ที่เป็นมาตรฐาน ในที่นี้เราต้องการข้อมูลเขตจังหวัดของประเทศไทย
ดาวน์โหลดข้อมูลจังหวัดไทย:#
- เข้าไปที่ simplemaps.com/gis/country/th ↗
- คลิกปุ่ม “Download GeoJSON”
- บันทึกไฟล์ชื่อ
thailand-provinces.json
จัดเก็บไฟล์:#
# สร้างโฟลเดอร์สำหรับเก็บข้อมูล
mkdir -p public/data
# วางไฟล์ thailand-provinces.json ใน public/data/bashโครงสร้าง GeoJSON ที่ได้:#
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"id": "10",
"properties": {
"id": "10",
"name": "Bangkok",
"PROV_NAMT": "กรุงเทพมหานคร"
},
"geometry": {
"type": "Polygon",
"coordinates": [[
[100.5, 13.7],
[100.6, 13.8],
...
]]
}
}
]
}jsonอธิบายโครงสร้าง GeoJSON:#
| ฟิลด์ | คำอธิบาย |
|---|---|
| type | ประเภทของ GeoJSON (FeatureCollection = หลาย features) |
| features | Array ของ geographic features |
| id | รหัสจังหวัด (ใช้อ้างอิงในการ color) |
| properties | ข้อมูลเพิ่มเติม (ชื่อจังหวัด, ภาษาไทย, ฯลฯ) |
| geometry | ข้อมูลพิกัด geographic (Polygon, LineString, ฯลฯ) |
| coordinates | Array ของพิกัด [longitude, latitude] |
ขั้นตอนที่ 4: สร้าง Data Generation Library#
สร้างไฟล์ lib/election-data.ts สำหรับสร้างข้อมูลการเลือกตั้งแบบ mock:
// lib/election-data.ts
// Party types
export type Party = 'pink' | 'yellow';
export interface Candidate {
id: string;
name: string;
party: Party;
votes: number;
}
export interface ProvinceElectionData {
provinceId: string;
provinceName: string;
candidates: [Candidate, Candidate]; // Exactly 2 candidates
winner: Party;
totalVotes: number;
}
export interface NationalStats {
totalVotes: number;
pinkPartyVotes: number;
yellowPartyVotes: number;
pinkPartySeats: number;
yellowPartySeats: number;
totalProvinces: number;
}
// Party colors and names
export const PARTY_COLORS = {
pink: '#ec4899', // pink-500
yellow: '#eab308', // yellow-500
} as const;
export const PARTY_NAMES = {
pink: 'พรรคสีชมพู',
yellow: 'พรรคสีเหลือง',
} as const;tsฟีเจอร์ของ Data Library:#
| ฟีเจอร์ | คำอธิบาย |
|---|---|
| Party Types | ใช้ TypeScript union types เพื่อความปลอดภัย |
| Candidate Interface | เก็บข้อมูลผู้สมัคร (ชื่อ, พรรค, คะแนน) |
| ProvinceElectionData | เก็บข้อมูลการเลือกตั้งแต่ละจังหวัด |
| NationalStats | สรุปผลการเลือกตั้งทั่วประเทศ |
| Thai Name Generation | สร้างชื่อผู้สมัครแบบสุ่มด้วยชื่อไทย |
ขั้นตอนที่ 5: สร้าง Map Component#
สร้างไฟล์ components/ElectionMap.tsx สำหรับแสดงแผนที่:
5.1 โครงสร้าง Component#
'use client';
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { Map as MapLibreMap } from 'react-map-gl/maplibre';
import type { FeatureCollection } from 'geojson';
import type { ProvinceElectionData, Party } from '@/lib/election-data';
import 'maplibre-gl/dist/maplibre-gl.css';
interface ElectionMapProps {
geoJson: FeatureCollection;
electionData: ProvinceElectionData[];
onProvinceSelect: (provinceId: string | null) => void;
}
export default function ElectionMap({
geoJson,
electionData,
onProvinceSelect,
}: ElectionMapProps) {
// ... component implementation
}tsxสำคัญ:
'use client'- จำเป็นเพราะ MapLibre ใช้ browser APIsimport CSS- ต้อง import maplibre CSS เพื่อให้แสดงผลถูกต้อง
5.2 Map Style Configuration#
ตั้งค่า map style เพื่อใช้ OpenStreetMap tiles พร้อม GeoJSON source:
<MapLibreMap
ref={mapRef}
initialViewState={{
longitude: 101.5,
latitude: 13.5,
zoom: 5.5,
}}
minZoom={4}
maxZoom={12}
mapStyle={{
version: 8,
sources: {
'osm': {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors',
},
'provinces': {
type: 'geojson',
data: geoJson,
},
},
layers: [
{
id: 'osm-tiles',
type: 'raster',
source: 'osm',
minzoom: 0,
maxzoom: 19,
},
// ... province layers
],
}}
/>tsxคำอธิบาย:
| ส่วนประกอบ | อธิบาย |
|---|---|
| version: 8 | MapLibre style specification version 8 |
| sources.osm | OpenStreetMap tiles เป็น base layer |
| sources.provinces | GeoJSON data สำหรับขอบเขตจังหวัด |
| initialViewState | ตำแหน่งเริ่มต้นของแผนที่ (กลางประเทศไทย) |
5.3 Dynamic Province Coloring#
ใช้ MapLibre Expression Syntax เพื่อ color จังหวัดตามผู้ชนะการเลือกตั้ง:
// Generate fill-color expression
const fillColorExpression = useMemo(() => {
const expression: any[] = ['case'];
// Add color conditions for each province
for (const feature of geoJson.features) {
const provinceId = feature.properties?.id;
const winner = provinceWinners.get(provinceId);
if (winner === 'pink') {
expression.push(['==', ['get', 'id'], provinceId], '#ec4899');
} else if (winner === 'yellow') {
expression.push(['==', ['get', 'id'], provinceId], '#eab308');
}
}
// Add default color
expression.push('#d1d5db');
return expression;
}, [geoJson, provinceWinners]);
// Use in layer
{
id: 'provinces-fill',
type: 'fill',
source: 'provinces',
paint: {
'fill-color': fillColorExpression,
'fill-opacity': [
'case',
['boolean', ['feature-state', 'selected'], false],
0.8,
['boolean', ['feature-state', 'hovered'], false],
0.7,
0.5,
],
},
}tsxอธิบาย Expression Syntax:
// Expression จะถูกแปลงเป็น:
'case', // ฟังก์ชัน case
['==', ['get', 'id'], '10'], '#ec4899', // ถ้า id = 10 ให้สีชมพู
['==', ['get', 'id'], '11'], '#eab308', // ถ้า id = 11 ให้สีเหลือง
// ... ทุกจังหวัด
'#d1d5db' // ถ้าไม่ตรงกับใครเลย ให้สีเทาjavascriptข้อดีของ Expression:
- ประมวลผลบน GPU (เร็ว)
- ไม่ต้อง loop ใน JavaScript
- Support real-time updates
- ใช้
feature-stateเพื่อ hover/selection effects
5.4 Click-to-Select พร้อม Feature State#
จัดการเมื่อผู้ใช้คลิกที่จังหวัด:
// Handle click on provinces
const onClick = useCallback((e: any) => {
const feature = e.features?.[0];
if (!feature) return;
const provinceId = feature.properties?.id;
if (selectedProvinceId === provinceId) {
// Deselect if clicking the same province
handleReset();
} else {
setSelectedProvinceId(provinceId);
onProvinceSelect(provinceId);
}
}, [selectedProvinceId, onProvinceSelect, handleReset]);
// Update feature state when selection changes
useEffect(() => {
const map = mapRef.current;
if (!map) return;
// Clear previous selection
provinceFeatureIds.featureMap.forEach((featureId) => {
map.getMap().setFeatureState(
{ source: 'provinces', id: featureId },
{ selected: false }
);
});
// Set new selection
if (selectedProvinceId) {
const featureId = provinceFeatureIds.featureMap.get(selectedProvinceId);
if (featureId !== undefined) {
map.getMap().setFeatureState(
{ source: 'provinces', id: featureId },
{ selected: true }
);
}
}
}, [selectedProvinceId, provinceFeatureIds]);
// Hover state effect
useEffect(() => {
const map = mapRef.current;
if (!map) return;
// Clear all hovers
provinceFeatureIds.featureMap.forEach((featureId) => {
map.getMap().setFeatureState(
{ source: 'provinces', id: featureId },
{ hovered: false }
);
});
// Set new hover
if (hoveredProvinceId) {
const featureId = provinceFeatureIds.featureMap.get(hoveredProvinceId);
if (featureId !== undefined) {
map.getMap().setFeatureState(
{ source: 'provinces', id: featureId },
{ hovered: true }
);
}
}
}, [hoveredProvinceId, provinceFeatureIds]);tsxคำอธิบาย Feature State:
| Feature State | อธิบาย |
|---|---|
| selected | ใช้ track จังหวัดที่ถูกเลือก (สำหรับ highlight) |
| hovered | ใช้ track จังหวัดที่ถูก hover (สำหรับ opacity) |
| setFeatureState | API สำหรับตั้งค่า state ของ feature |
5.5 Fit Bounds Animation#
บินไปยังจังหวัดที่เลือกด้วย fitBounds:
// Calculate province bounds
const provinceFeatureIds = useMemo(() => {
const featureMap = new Map<string, number>();
const boundsMap = new Map<string, [number, number, number, number]>();
geoJson.features.forEach((feature, index) => {
const provinceId = feature.properties?.id;
if (provinceId) {
featureMap.set(provinceId, feature.id || index);
// Calculate bounds for this province
if (feature.geometry?.type === 'Polygon') {
const coordinates = feature.geometry.coordinates[0] as [number, number][];
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
coordinates.forEach(([lon, lat]) => {
minX = Math.min(minX, lon);
minY = Math.min(minY, lat);
maxX = Math.max(maxX, lon);
maxY = Math.max(maxY, lat);
});
boundsMap.set(provinceId, [minX, minY, maxX, maxY]);
}
}
});
return { featureMap, boundsMap };
}, [geoJson]);
// Fly to province when selected
useEffect(() => {
const map = mapRef.current;
if (!map || !selectedProvinceId) return;
const bounds = provinceFeatureIds.boundsMap.get(selectedProvinceId);
if (bounds) {
const [minX, minY, maxX, maxY] = bounds;
const padding = 50;
map.getMap().fitBounds(
[
[minX, minY],
[maxX, maxY]
],
{
padding: { top: padding, bottom: padding, left: padding, right: padding },
duration: 1000,
}
);
}
}, [selectedProvinceId, provinceFeatureIds]);tsxคำอธิบาย:
| พารามิเตอร์ | อธิบาย |
|---|---|
| bounds | กรอบพิกัด [[minX, minY], [maxX, maxY]] |
| padding | ระยะห่างจากขอบ (pixel) |
| duration | ระยะเวลา animation (ms) |
ฟีเจอร์หลัก#
1. MapLibre GL กับ OpenStreetMap Tiles#
แผนที่ใช้ OpenStreetMap tiles เป็นฐาน (base layer) ซึ่งให้รายละเอียดภูมิศาสตร์โดยไม่ต้องใช้ API Key
2. Dynamic Election Data Visualization#
การใช้ MapLibre Expression เพื่อแสดงผลข้อมูลการเลือกตั้งแบบ dynamic:
// Expression ประมวลผลบน GPU ทำให้เร็วมาก
'fill-color': [
'case',
['==', ['get', 'id'], '10'], '#ec4899', // จังหวัด 10 = ชมพูชนะ
['==', ['get', 'id'], '11'], '#eab308', // จังหวัด 11 = เหลืองชนะ
'#d1d5db' // default = สีเทา
]tsxประโยชน์:
- Real-time updates - เปลี่ยนข้อมูลทันที
- GPU-accelerated - ประมวลผลบนการ์ดจอ
- เหมาะกับ large datasets (77 จังหวัด)
3. Feature State สำหรับ Hover และ Selection#
ใช้ feature-state เพื่อ track hover และ selection แบบ real-time:
paint: {
'fill-opacity': [
'case',
['boolean', ['feature-state', 'selected'], false], // ถ้า selected
0.8, // opacity = 0.8
['boolean', ['feature-state', 'hovered'], false], // ถ้า hovered
0.7, // opacity = 0.7
0.5, // default
],
'line-width': [
'case',
['boolean', ['feature-state', 'selected'], false], // ถ้า selected
3, // ขอบหนา 3px
['boolean', ['feature-state', 'hovered'], false], // ถ้า hovered
2, // ขอบหนา 2px
1, // default
],
}tsx4. Information Panel Component#
สร้างไฟล์ components/ElectionInfo.tsx สำหรับแสดงข้อมูลการเลือกตั้ง:
'use client';
import { useMemo } from 'react';
import type { ProvinceElectionData, NationalStats } from '@/lib/election-data';
import { PARTY_COLORS, PARTY_NAMES } from '@/lib/election-data';
interface ElectionInfoProps {
electionData: ProvinceElectionData[];
nationalStats: NationalStats;
selectedProvinceId: string | null;
}
export default function ElectionInfo({
electionData,
nationalStats,
selectedProvinceId,
}: ElectionInfoProps) {
const selectedProvince = useMemo(() => {
if (!selectedProvinceId) return null;
return electionData.find((p) => p.provinceId === selectedProvinceId) || null;
}, [selectedProvinceId, electionData]);
return (
<div className="h-full flex flex-col bg-white dark:bg-gray-900 rounded-lg">
{/* Header */}
<div className="p-6 border-b">
<h1 className="text-2xl font-bold">การเลือกตั้งทั่วไป พ.ศ. 3569</h1>
</div>
{/* Content - National Overview or Province Detail */}
{selectedProvince ? (
// Province Detail View
<div>...</div>
) : (
// National Overview View
<div>...</div>
)}
</div>
);
}tsx5. Animations เพื่อ UX ที่ดี#
เพิ่ม animations ใน app/globals.css:
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-out forwards;
}
.animate-slideUp {
animation: slideUp 0.4s ease-out forwards;
}
.animate-scaleIn {
animation: scaleIn 0.3s ease-out forwards;
}css6. Responsive Design#
แผนที่ปรับขนาดตามหน้าจอ:
// Desktop: 3 columns (1:2 ratio)
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 h-full">
<div className="lg:col-span-1"> {/* Info panel - 33% */}
<div className="lg:col-span-2"> {/* Map - 67% */}
</div>
// Mobile: 1 column (stacked)
<div className="grid grid-cols-1">
<div> {/* Info panel - 100% */}
<div> {/* Map - 100% */}
</div>tsxTailwind Breakpoints:
sm- 640px (Mobile landscape)lg- 1024px (Desktop)grid-cols-1- Mobile: 1 columnlg:grid-cols-3- Desktop: 3 columns
7. Tailwind CSS v4 Configuration#
โปรเจกต์นี้ใช้ Tailwind CSS v4 ซึ่งต่างจาก v3 ตรงที่ใช้ @import แทน @tailwind directives:
/* app/globals.css */
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}cssความแตกต่าง Tailwind v3 vs v4:
| ฟีเจอร์ | v3 | v4 |
|---|---|---|
| Import syntax | @tailwind base; | @import "tailwindcss"; |
| Config file | tailwind.config.js | ใช้ใน CSS กับ @theme |
| PostCSS | ต้อง @tailwindcss/postcss | ใช้ @tailwindcss/postcss เหมือนกัน |
| Performance | เร็ว | เร็วขึ้น (ใหม่) |
8. Dark Mode Support#
รองรับ Dark mode ด้วย Tailwind:
className="bg-white dark:bg-gray-900"
className="text-gray-900 dark:text-white"
className="border-gray-200 dark:border-gray-700"tsxขั้นตอนที่ 6: รัน Application#
เริ่ม development server:
npm run devbashเปิด browser ที่ http://localhost:3000
ผลลัพธ์:#
- แผนที่ประเทศไทย 77 จังหวัด
- สีชมพู/เหลือง ตามผู้ชนะการเลือกตั้งในแต่ละจังหวัด
- สามารถคลิกเลือกจังหวัดได้
- Side panel แสดงรายละเอียดผู้สมัครและคะแนนเสียง
- Reset button เพื่อกลับสู่มุมมองเริ่มต้น
- Hover effects เมื่อเอาเมาส์ไปชี้ที่จังหวัด
- Dark mode support
ไอเดียการปรับแต่งเพิ่มเติม#
ขยายฟีเจอร์ของแผนที่การเลือกตั้งด้วย:
| ฟีเจอร์ | รายละเอียด | วิธีทำ |
|---|---|---|
| Multi-Party Support | รองรับพรรคการเมืองมากกว่า 2 พรรค | แก้ Party type เพิ่ม options |
| Historical Data | เปรียบเทียบผลการเลือกตั้งหลายครั้ง | เพิ่ม year selector และ data source |
| Constituency Details | แสดงผลระดับเขตเลือกตั้ง | ใช้ GeoJSON ที่ละเอียดกว่า |
| Voter Turnout | แสดงเปอร์เซ็นต์ผู้มาใช้สิทธิ | ใช้ interpolate expression |
| Search | ค้นหาจังหวัดด้วยชื่อ | เพิ่ม search bar + filter |
| Data Export | ส่งออกข้อมูลเป็น CSV/PDF | เพิ่ม export button ใช้ Blob API |
| Shareable Links | แชร์ลิงก์ไปยังจังหวัดที่เลือก | ใช้ URL params (e.g., ?province=10) |
ตัวอย่าง Choropleth Map (แสดงเปอร์เซ็นต์ผู้มาใช้สิทธิ):#
'fill-color': [
'interpolate', // สร้าง gradient สี
['linear'], // ประเภท interpolation
['get', 'turnout'], // ค่าเปอร์เซ็นต์
0, '#fee5d9', // 0% = สีแดงอ่อน
50, '#fcae91', // 50% = สีส้ม
70, '#fb6a4a', // 70% = สีแดง
100, '#de2d26' // 100% = สีแดงเข้ม
]tsxตัวอย่าง Multi-Party Support:#
// แก้ไข Party type
export type Party = 'pink' | 'yellow' | 'blue' | 'green';
// อัปเดต PARTY_COLORS และ PARTY_NAMES
export const PARTY_COLORS = {
pink: '#ec4899',
yellow: '#eab308',
blue: '#3b82f6',
green: '#22c55e',
} as const;
export const PARTY_NAMES = {
pink: 'พรรคสีชมพู',
yellow: 'พรรคสีเหลือง',
blue: 'พรรคสีฟ้า',
green: 'พรรคสีเขียว',
} as const;tsxข้อมูลลิขสิทธิ์#
| Component | License | ใช้เชิงพาณิชย์ได้ไหม? |
|---|---|---|
| MapLibre GL | BSD-3-Clause | ✅ ได้ ฟรี |
| OpenStreetMap | ODbL | ✅ ได้ ต้องใส่ attribution |
| react-map-gl | MIT | ✅ ได้ ฟรี |
| Next.js | MIT | ✅ ได้ ฟรี |
สำคัญ: ต้องใส่ attribution สำหรับ OpenStreetMap ตามข้อกำหนด ODbL license:
ตัวอย่าง Code แบบสมบูรณ์#
ตัวอย่างหน้าจอ

ไฟล์ app/page.tsx - Main Page#
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import type { FeatureCollection } from 'geojson';
import {
generateAllProvincesData,
calculateNationalStats,
type ProvinceElectionData,
type NationalStats,
} from '@/lib/election-data';
// Dynamically import ElectionMap to avoid SSR issues with maplibre-gl
const ElectionMap = dynamic(() => import('@/components/ElectionMap'), {
ssr: false,
loading: () => (
<div className="w-full h-full flex items-center justify-center bg-gray-100 dark:bg-gray-800 rounded-lg">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 dark:border-gray-100 mx-auto mb-4"></div>
<p className="text-gray-600 dark:text-gray-400">กำลังโหลดแผนที่...</p>
</div>
</div>
),
});
import ElectionInfo from '@/components/ElectionInfo';
export default function Home() {
const [geoJson, setGeoJson] = useState<FeatureCollection | null>(null);
const [electionData, setElectionData] = useState<ProvinceElectionData[]>([]);
const [nationalStats, setNationalStats] = useState<NationalStats | null>(null);
const [selectedProvinceId, setSelectedProvinceId] = useState<string | null>(null);
useEffect(() => {
// Load GeoJSON data
fetch('/data/thailand-provinces.json')
.then((response) => response.json())
.then((data) => {
setGeoJson(data);
// Generate election data
const provincesData = generateAllProvincesData(data);
setElectionData(provincesData);
// Calculate national statistics
const stats = calculateNationalStats(provincesData);
setNationalStats(stats);
})
.catch((error) => {
console.error('Error loading GeoJSON:', error);
});
// Listen for clear-selection event from ElectionInfo component
const handleClearSelection = () => {
setSelectedProvinceId(null);
};
window.addEventListener('clear-selection', handleClearSelection);
return () => {
window.removeEventListener('clear-selection', handleClearSelection);
};
}, []);
const handleProvinceSelect = (provinceId: string | null) => {
setSelectedProvinceId(provinceId);
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 p-4 md:p-6">
<div className="max-w-7xl mx-auto h-[calc(100vh-3rem)]">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 h-full">
{/* Left sidebar - Info panel */}
<div className="lg:col-span-1 h-full">
{nationalStats && (
<ElectionInfo
electionData={electionData}
nationalStats={nationalStats}
selectedProvinceId={selectedProvinceId}
/>
)}
</div>
{/* Right - Map */}
<div className="lg:col-span-2 h-full">
{geoJson ? (
<ElectionMap
geoJson={geoJson}
electionData={electionData}
onProvinceSelect={handleProvinceSelect}
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-100 dark:bg-gray-800 rounded-lg">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 dark:border-gray-100 mx-auto mb-4"></div>
<p className="text-gray-600 dark:text-gray-400">กำลังโหลดข้อมูล...</p>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}tsxไฟล์ app/layout.tsx - Root Layout#
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "แผนที่การเลือกตั้ง พ.ศ. 3569",
description: "ผลการเลือกตั้งทั่วไป 77 จังหวัดทั่วประเทศไทย",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}tsxไฟล์ components/ElectionMap.tsx - Map Component#
ดูตัวอย่าง code แบบสมบูรณ์ได้ที่ด้านบน (หัวข้อ 5.1 - 5.5)
ไฟล์ components/ElectionInfo.tsx - Info Panel Component#
ดูตัวอย่าง code แบบสมบูรณ์ได้ที่ด้านบน (หัวข้อ 4)
ไฟล์ lib/election-data.ts - Data Library#
ดูตัวอย่าง code แบบสมบูรณ์ได้ที่ด้านบน (หัวข้อ 4)
ไฟล์ app/globals.css - Global Styles#
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-out forwards;
}
.animate-slideUp {
animation: slideUp 0.4s ease-out forwards;
opacity: 0;
}
.animate-slideDown {
animation: slideDown 0.4s ease-out forwards;
}
.animate-scaleIn {
animation: scaleIn 0.3s ease-out forwards;
}cssโครงสร้างไฟล์ทั้งหมด:#
thailand-map/
├── app/
│ ├── layout.tsx # Root layout with fonts and metadata
│ ├── page.tsx # Main page with dynamic imports
│ └── globals.css # Global styles + animations
├── components/
│ ├── ElectionMap.tsx # Map component with MapLibre GL
│ └── ElectionInfo.tsx # Info panel component
├── lib/
│ └── election-data.ts # Data generation and types
├── public/
│ └── data/
│ └── thailand-provinces.json # GeoJSON data (ดาวน์โหลดเอง)
├── package.json
├── tsconfig.json
├── next.config.ts # Next.js config with transpilePackages
└── tailwind.config.ts # Tailwind CSS v4 configplaintextแหล่งข้อมูลเพิ่มเติม#
- MapLibre GL Documentation ↗ - เอกสารอย่างเป็นทางการ
- react-map-gl Documentation ↗ - React wrapper docs
- OpenStreetMap ↗ - แหล่งข้อมูลแผนที่ฟรี
- GeoJSON Specification ↗ - มาตรฐาน GeoJSON
- MapLibre Style Specification ↗ - รูปแบบ style spec