Back

สร้าง Interactive Map ด้วย Next.js และ MapLibre GLBlur image

🚀 ขอฝากครับ GLM Coding AI ช่วยเขียนโค้ดในราคาย่อมเยาว์ — เริ่มต้นเพียง $3/เดือน สมัครสมาชิกตอนนี้และรับข้อเสนอแบบจำกัดเวลา! Link: https://z.ai/subscribe?ic=3SSIIKOOLV

GLM Coding 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

แนะนำให้ศึกษา:


ขั้นตอนที่ 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-map
bash

คำอธิบายคำสั่ง:#

  • npx - เครื่องมือของ npm สำหรับรัน package โดยไม่ต้อง install
  • create-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 config
plaintext

ตั้งค่า 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-gl
bash

อธิบายแต่ละ package:#

Packageวัตถุประสงค์
maplibre-glCore library สำหรับแสดงแผนที่ (JavaScript)
react-map-glReact wrapper ที่ทำให้ใช้ MapLibre GL ใน React ได้ง่ายขึ้น
@types/geojsonTypeScript definitions สำหรับ GeoJSON format
@types/maplibre-glTypeScript 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 เพื่อให้ทำงานได้กับ TypeScript
  • webpack.config - ตั้งค่า webpack ให้ไม่แสดง warning เกี่ยวกับ unknown context

ขั้นตอนที่ 3: ดาวน์โหลดข้อมูล GeoJSON#

GeoJSON คือรูปแบบข้อมูล geographic ที่เป็นมาตรฐาน ในที่นี้เราต้องการข้อมูลเขตจังหวัดของประเทศไทย

ดาวน์โหลดข้อมูลจังหวัดไทย:#

  1. เข้าไปที่ simplemaps.com/gis/country/th
  2. คลิกปุ่ม “Download GeoJSON”
  3. บันทึกไฟล์ชื่อ 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)
featuresArray ของ geographic features
idรหัสจังหวัด (ใช้อ้างอิงในการ color)
propertiesข้อมูลเพิ่มเติม (ชื่อจังหวัด, ภาษาไทย, ฯลฯ)
geometryข้อมูลพิกัด geographic (Polygon, LineString, ฯลฯ)
coordinatesArray ของพิกัด [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 APIs
  • import 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: '&copy; OpenStreetMap contributors',
      },
      'provinces': {
        type: 'geojson',
        data: geoJson,
      },
    },
    layers: [
      {
        id: 'osm-tiles',
        type: 'raster',
        source: 'osm',
        minzoom: 0,
        maxzoom: 19,
      },
      // ... province layers
    ],
  }}
/>
tsx

คำอธิบาย:

ส่วนประกอบอธิบาย
version: 8MapLibre style specification version 8
sources.osmOpenStreetMap tiles เป็น base layer
sources.provincesGeoJSON 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)
setFeatureStateAPI สำหรับตั้งค่า 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
  ],
}
tsx

4. 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>
  );
}
tsx

5. 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;
}
css

6. 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>
tsx

Tailwind Breakpoints:

  • sm - 640px (Mobile landscape)
  • lg - 1024px (Desktop)
  • grid-cols-1 - Mobile: 1 column
  • lg: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:

ฟีเจอร์v3v4
Import syntax@tailwind base;@import "tailwindcss";
Config filetailwind.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 dev
bash

เปิด 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

ข้อมูลลิขสิทธิ์#

ComponentLicenseใช้เชิงพาณิชย์ได้ไหม?
MapLibre GLBSD-3-Clause✅ ได้ ฟรี
OpenStreetMapODbL✅ ได้ ต้องใส่ attribution
react-map-glMIT✅ ได้ ฟรี
Next.jsMIT✅ ได้ ฟรี

สำคัญ: ต้องใส่ 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 config
plaintext

แหล่งข้อมูลเพิ่มเติม#

สร้าง Interactive Map ด้วย Next.js และ MapLibre GL
ผู้เขียน กานต์ ยงศิริวิทย์ / Karn Yongsiriwit
เผยแพร่เมื่อ February 3, 2027
ลิขสิทธิ์ CC BY-NC-SA 4.0

กำลังโหลดความคิดเห็น...

ความคิดเห็น 0