// =============================== // IMPORT D3 // =============================== import * as d3 from 'https://cdn.jsdelivr.net/npm/d3@7/+esm'; // =============================== // SETTINGS & STATE // =============================== const width = 1000; const height = 1300; const rectSize = 10; // ขนาดของสี่เหลี่ยม (แทน dotRadius) const dotGap = 14; let currentMode = 'problem'; let currentParty = 'ทั้งหมด'; // เปลี่ยนจาก 'ประชาชน' เป็น 'ทั้งหมด' เพื่อรองรับสถานะเริ่มต้น let currentElectionType = 'district'; // เพิ่ม State เพื่อเก็บประเภทการเลือกตั้ง // --- เพิ่ม Color Matrix สำหรับ Bivariate Choropleth --- // ปรับแก้สีให้ตรงกับตารางใน .html (Row = ความผิดปกติ, Col = คะแนนชนะขาด) const bivariateColors = [ ['#e8e8e8', '#dfb0d6', '#be64ac'], // [None-Marginal, None-Moderate, None-Safe] -> ปัญหาน้อย (สูสี, กลาง, ชนะขาด) ['#ace4e4', '#a5add3', '#8c62aa'], // [Minor-Marginal, Minor-Moderate, Minor-Safe] -> ปัญหากลาง (สูสี, กลาง, ชนะขาด) ['#5ac8c8', '#56a0d3', '#3b4994'], // [Major-Marginal, Major-Moderate, Major-Safe] -> ปัญหามาก (สูสี, กลาง, ชนะขาด) ]; // เพิ่มข้อมูลสำหรับ Summary Card (สลับตามปุ่ม Radio) const summaryData = { district: { total: 327, percent: '81.75 %', note: '*เฉพาะแบบแบ่งเขต', }, party: { total: 312, percent: '78.00 %', note: '*เฉพาะแบบบัญชีรายชื่อ', }, }; const mapArea = d3.select('#map-area'); const chartArea = d3.select('.chart-container-box'); const layout = d3.select('.dashboard-layout'); // รายชื่อพรรคตามที่ระบุในรูปภาพและคำสั่ง const parties = [ 'ทั้งหมด', // เพิ่ม 'ทั้งหมด' เข้าไปในลิสต์เพื่อให้กดเลือกได้ 'ประชาชน', 'ภูมิใจไทย', 'เพื่อไทย', 'กล้าธรรม', 'ประชาธิปัตย์', 'พลังประชารัฐ', 'ไทรวมพลัง', 'ไทยสร้างไทย', 'โอกาสใหม่', 'ประชาชาติ', 'รวมไทยสร้างชาติ', ]; // ✨ ตกแต่ง Tooltip ให้สวยและมีมิติขึ้น const tooltip = d3 .select('body') .append('div') .attr('class', 'tooltip-d3') .style('position', 'absolute') .style('visibility', 'hidden') .style('pointer-events', 'none') .style('z-index', '9999') .style('background', 'rgba(255, 255, 255, 0.95)') .style('backdrop-filter', 'blur(4px)') .style('padding', '12px 16px') .style('border-radius', '8px') .style('box-shadow', '0 4px 12px rgba(0,0,0,0.15)') .style('border', '1px solid #eee') .style('font-family', 'sans-serif') .style('color', '#333'); // =============================== // DATA (คงเดิมทุกประการ ไม่มีการย่อบรรทัด) // =============================== const rawData = [ // --- ภาคเหนือ --- { name: 'เชียงราย', x: 300, y: 50, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1], [0, 2], ], }, { name: 'เชียงใหม่', x: 210, y: 80, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1], [0, 2], [1, 2], [2, 2], [0, 3], ], }, { name: 'แม่ฮ่องสอน', x: 130, y: 110, dots: [ [0, 0], [1, 0], ], }, { name: 'พะเยา', x: 340, y: 100, dots: [ [0, 0], [1, 0], [2, 0], ], }, { name: 'น่าน', x: 430, y: 130, dots: [ [0, 0], [1, 0], [2, 0], ], }, { name: 'ลำพูน', x: 210, y: 160, dots: [ [0, 0], [1, 0], ], }, { name: 'ลำปาง', x: 280, y: 150, dots: [ [0, 0], [1, 0], [0, 1], [1, 1], ], }, { name: 'แพร่', x: 370, y: 150, dots: [ [0, 0], [1, 0], [2, 0], ], }, { name: 'อุตรดิตถ์', x: 370, y: 195, dots: [ [0, 0], [1, 0], [2, 0], ], }, { name: 'ตาก', x: 210, y: 220, dots: [ [0, 0], [1, 0], [0, 1], ], }, { name: 'สุโขทัย', x: 280, y: 220, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], ], }, { name: 'พิษณุโลก', x: 380, y: 240, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], ], }, { name: 'พิจิตร', x: 350, y: 290, dots: [ [0, 0], [1, 0], [2, 0], ], }, { name: 'กำแพงเพชร', x: 270, y: 280, dots: [ [0, 0], [1, 0], [2, 0], [3, 0], ], }, { name: 'เพชรบูรณ์', x: 440, y: 270, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1], ], }, { name: 'นครสวรรค์', x: 310, y: 330, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1], ], }, { name: 'อุทัยธานี', x: 240, y: 340, dots: [ [0, 0], [1, 0], ], }, // --- ภาคอีสาน --- { name: 'เลย', x: 510, y: 180, dots: [ [0, 0], [1, 0], [0, 1], [1, 1], ], }, { name: 'หนองคาย', x: 620, y: 160, dots: [ [0, 0], [1, 0], [2, 0], ], }, { name: 'บึงกาฬ', x: 700, y: 140, dots: [ [0, 0], [1, 0], [2, 0], ], }, { name: 'หนองบัวลำภู', x: 550, y: 210, dots: [ [0, 0], [1, 0], [0, 1], ], }, { name: 'อุดรธานี', x: 630, y: 210, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1], [0, 2], [1, 2], [2, 2], [0, 3], ], }, { name: 'สกลนคร', x: 730, y: 190, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1], [0, 2], ], }, { name: 'นครพนม', x: 810, y: 200, dots: [ [0, 0], [1, 0], [0, 1], [1, 1], ], }, { name: 'มุกดาหาร', x: 820, y: 260, dots: [ [0, 0], [1, 0], ], }, { name: 'กาฬสินธุ์', x: 730, y: 260, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1], ], }, { name: 'ขอนแก่น', x: 590, y: 280, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1], [0, 2], [1, 2], [2, 2], [0, 3], [1, 3], ], }, { name: 'ชัยภูมิ', x: 510, y: 300, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1], [0, 2], ], }, { name: 'มหาสารคาม', x: 670, y: 310, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1], ], }, { name: 'ร้อยเอ็ด', x: 740, y: 320, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1], [0, 2], [1, 2], ], }, { name: 'ยโสธร', x: 800, y: 330, dots: [ [0, 0], [1, 0], [2, 0], ], }, { name: 'อำนาจเจริญ', x: 850, y: 330, dots: [ [0, 0], [1, 0], ], }, { name: 'อุบลราชธานี', x: 890, y: 360, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1], [0, 2], [1, 2], [2, 2], [0, 3], [1, 3], ], }, { name: 'นครราชสีมา', x: 550, y: 390, dots: [ [0, 0], [1, 0], [2, 0], [3, 0], [0, 1], [1, 1], [2, 1], [3, 1], [0, 2], [1, 2], [2, 2], [3, 2], [0, 3], [1, 3], [2, 3], [3, 3], ], }, { name: 'บุรีรัมย์', x: 660, y: 400, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1], [0, 2], [1, 2], [2, 2], [0, 3], ], }, { name: 'สุรินทร์', x: 740, y: 420, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1], [0, 2], [1, 2], ], }, { name: 'ศรีสะเกษ', x: 810, y: 430, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1], [0, 2], [1, 2], [2, 2], ], }, // --- ภาคกลาง --- { name: 'ชัยนาท', x: 350, y: 400, dots: [ [0, 0], [1, 0], ], }, { name: 'สิงห์บุรี', x: 410, y: 390, dots: [[0, 0]] }, { name: 'อ่างทอง', x: 410, y: 420, dots: [ [0, 0], [1, 0], ], }, { name: 'ลพบุรี', x: 470, y: 390, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], ], }, { name: 'สระบุรี', x: 500, y: 430, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], ], }, { name: 'อยุธยา', x: 360, y: 450, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], ], }, { name: 'สุพรรณบุรี', x: 270, y: 440, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], ], }, { name: 'กาญจนบุรี', x: 190, y: 430, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], ], }, { name: 'นครปฐม', x: 290, y: 490, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1], ], }, { name: 'นนทบุรี', x: 360, y: 500, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1], [0, 2], [1, 2], ], }, { name: 'ปทุมธานี', x: 440, y: 490, dots: [ [0, 0], [1, 0], [2, 0], [3, 0], [0, 1], [1, 1], [2, 1], [3, 1], ], }, { name: 'กรุงเทพมหานคร', x: 410, y: 550, dots: [ [0, 0], [1, 0], [2, 0], [3, 0], [4, 0], [5, 0], [6, 0], [0, 1], [1, 1], [2, 1], [3, 1], [4, 1], [5, 1], [6, 1], [0, 2], [1, 2], [2, 2], [3, 2], [4, 2], [5, 2], [6, 2], [0, 3], [1, 3], [2, 3], [3, 3], [4, 3], [5, 3], [6, 3], [0, 4], [1, 4], [2, 4], [3, 4], [4, 4], ], }, { name: 'สมุทรปราการ', x: 510, y: 600, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1], [0, 2], [1, 2], ], }, { name: 'สมุทรสาคร', x: 330, y: 580, dots: [ [0, 0], [1, 0], [0, 1], [1, 1], ], }, { name: 'สมุทรสงคราม', x: 410, y: 640, dots: [[0, 0]] }, { name: 'ราชบุรี', x: 260, y: 550, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], ], }, { name: 'นครนายก', x: 560, y: 460, dots: [ [0, 0], [1, 0], ], }, { name: 'ปราจีนบุรี', x: 610, y: 485, dots: [ [0, 0], [1, 0], [2, 0], ], }, { name: 'สระแก้ว', x: 685, y: 485, dots: [ [0, 0], [1, 0], [2, 0], ], }, { name: 'ฉะเชิงเทรา', x: 590, y: 540, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], ], }, { name: 'ชลบุรี', x: 620, y: 600, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1], [0, 2], [1, 2], [2, 2], [0, 3], ], }, { name: 'ระยอง', x: 600, y: 670, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], ], }, { name: 'จันทบุรี', x: 680, y: 630, dots: [ [0, 0], [1, 0], [2, 0], ], }, { name: 'ตราด', x: 680, y: 690, dots: [[0, 0]] }, // --- ภาคใต้ --- { name: 'เพชรบุรี', x: 300, y: 650, dots: [ [0, 0], [1, 0], [0, 1], ], }, { name: 'ประจวบคีรีขันธ์', x: 290, y: 690, dots: [ [0, 0], [1, 0], [0, 1], ], }, { name: 'ชุมพร', x: 250, y: 770, dots: [ [0, 0], [1, 0], [0, 1], ], }, { name: 'ระนอง', x: 210, y: 810, dots: [[0, 0]] }, { name: 'สุราษฎร์ธานี', x: 230, y: 860, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1], [0, 2], ], }, { name: 'พังงา', x: 140, y: 880, dots: [ [0, 0], [1, 0], ], }, { name: 'ภูเก็ต', x: 150, y: 930, dots: [ [0, 0], [1, 0], [2, 0], ], }, { name: 'กระบี่', x: 230, y: 920, dots: [ [0, 0], [1, 0], [2, 0], ], }, { name: 'นครศรีธรรมราช', x: 310, y: 900, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1], [0, 2], [1, 2], [2, 2], ], }, { name: 'ตรัง', x: 290, y: 980, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], ], }, { name: 'พัทลุง', x: 350, y: 980, dots: [ [0, 0], [1, 0], [0, 1], ], }, { name: 'สงขลา', x: 400, y: 1020, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1], [0, 2], [1, 2], [2, 2], ], }, { name: 'สตูล', x: 340, y: 1040, dots: [ [0, 0], [1, 0], ], }, { name: 'ปัตตานี', x: 410, y: 1110, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], ], }, { name: 'ยะลา', x: 390, y: 1160, dots: [ [0, 0], [1, 0], [2, 0], ], }, { name: 'นราธิวาส', x: 450, y: 1160, dots: [ [0, 0], [1, 0], [2, 0], [0, 1], [1, 1], ], }, ]; let provinces = []; // จะเก็บข้อมูลหลังจากโหลด CSV // =============================== // FUNCTION: RENDER DASHBOARD // =============================== function renderDashboard() { mapArea.html(''); // ✨ สร้าง Layout ส่วนหัวเพื่อจัดระเบียบ Filter กับ ปุ่ม Reset Zoom ให้อยู่ระดับเดียวกัน const headerContainer = mapArea .append('div') .style('display', 'flex') .style('justify-content', 'space-between') .style('align-items', 'center') .style('margin-bottom', '20px') .style('flex-wrap', 'wrap') .style('gap', '10px'); // --- สร้าง Party Filter ด้านบน --- const filterContainer = headerContainer .append('div') .attr('class', 'party-filter-container') .style('display', 'flex') .style('flex-wrap', 'wrap') .style('gap', '8px'); filterContainer .selectAll('.party-pill') .data(parties) .join('div') .attr('class', 'party-pill') .text((d) => d) .style('padding', '6px 16px') .style('border', '1px solid #ccc') .style('border-radius', '20px') .style('cursor', 'pointer') .style('font-size', '14px') .style('background-color', (d) => currentParty === d ? '#f0f0f0' : 'white', ) .style('border-color', (d) => currentParty === d ? '#333' : '#ccc', ) .style('font-weight', (d) => currentParty === d ? 'bold' : 'normal', ) .style('transition', 'all 0.2s') // ✨ เพิ่ม Transition ตอนกดปุ่ม Filter .on('click', function (event, d) { currentParty = d; renderDashboard(); // รีเรนเดอร์เพื่ออัปเดตสี }); const dynamicTitle = d3.select('#chart-title'); const legendTitle = d3.select('#legend-title'); if (currentMode === 'problem') { dynamicTitle.text('จำนวนปัญหาที่พบในแต่ละเขต'); legendTitle.text('คำอธิบายสี (Bivariate)'); } else if (currentMode === 'invalid') { dynamicTitle.text('สัดส่วนบัตรเสียในแต่ละเขต'); legendTitle.text('คำอธิบายสี (Bivariate)'); } else if (currentMode === 'user') { dynamicTitle.text('สัดส่วนผู้มาใช้สิทธิในแต่ละเขต'); legendTitle.text('คำอธิบายสี (Bivariate)'); } // ✨ สร้าง SVG โดยใช้ Backticks (`) ให้ถูกต้องเพื่อไม่ให้ Map หาย const svg = mapArea .append('svg') .attr('viewBox', 0 0 ${width} ${height}) .attr('preserveAspectRatio', 'xMidYMid meet') .style('width', '100%') .style('height', 'auto') .style('background', 'transparent') .style('cursor', 'grab'); const g = svg.append('g'); const zoom = d3 .zoom() .scaleExtent([0.5, 8]) .on('zoom', (event) => { g.attr('transform', event.transform); }); svg.call(zoom); // ✨ ปุ่ม Reset Zoom คืนค่ามุมมอง headerContainer .append('button') .text('🔍 คืนค่ามุมมอง') .style('padding', '8px 16px') .style('border-radius', '8px') .style('border', '1px solid #ccc') .style('background', '#fff') .style('cursor', 'pointer') .style('font-size', '14px') .style('transition', '0.2s') .on('mouseover', function() { d3.select(this).style('background', '#f9f9f9'); }) .on('mouseout', function() { d3.select(this).style('background', '#fff'); }) .on('click', () => { svg.transition().duration(750).call(zoom.transform, d3.zoomIdentity); }); // ✨ ใช้ Backticks (`) ให้ถูกต้องในส่วนของ Transform const provinceGroups = g .selectAll('g.province') .data(provinces) .join('g') .attr('transform', (d) => translate(${d.x}, ${d.y})); // ชื่อจังหวัด provinceGroups .append('text') .attr('y', -10) .style('font-size', '14px') .style('font-weight', 'bold') .style('fill', '#333') .text((d) => d.name); // วาดสี่เหลี่ยมเขตเลือกตั้ง (Rects แทน Circles) provinceGroups.each(function (d) { d3.select(this) .selectAll('rect') .data(d.dots) .join('rect') .attr( 'x', (dot) => dot.pos[0] * dotGap - rectSize / 2, ) .attr( 'y', (dot) => dot.pos[1] * dotGap - rectSize / 2, ) .attr('width', rectSize) .attr('height', rectSize) .attr('rx', 2) // ✨ ทำให้มุมมนมากขึ้นเล็กน้อย .attr('stroke', '#ffffff') // ✨ ใส่เส้นขอบขาวเพื่อให้แต่ละสี่เหลี่ยมแยกกันชัดเจน .attr('stroke-width', 0.5) .attr('fill', (dot) => { // --- ส่วนการคำนวณสีตามการ Filter พรรค --- if (currentParty !== 'ทั้งหมด') { const winner = dot.data['เขตอันดับ 1']; if (winner !== currentParty) { return '#f0f0f0'; // ถ้าไม่ชนะให้เป็นสีเทาจาง } } // --- คำนวณสีแบบ Bivariate --- const yMap = { None: 0, Minor: 1, Major: 2 }; const xMap = { Marginal: 0, Moderate: 1, 'Safe-seat': 2, }; const anomalyCol = currentElectionType === 'district' ? 'ความผิดปกติ(เขต)' : 'ความผิดปกติ(ปาร์ตี้ลิส)'; const marginCol = currentElectionType === 'district' ? 'คะแนนชนะขาด(เขต)' : 'คะแนนชนะขาด(ปาร์ตี้ลิส)'; const yIdx = yMap[dot.data[anomalyCol]] ?? 0; const xIdx = xMap[dot.data[marginCol]] ?? 0; return bivariateColors[yIdx][xIdx]; }) .style('cursor', 'pointer') .on('mouseover', function (event, dot) { // ✨ ใส่ Animation เด้งขึ้นมาเวลา Hover และดึง Layer มาบนสุด (raise) d3.select(this) .raise() .transition().duration(200) .attr('width', rectSize + 6) .attr('height', rectSize + 6) .attr( 'x', dot.pos[0] * dotGap - (rectSize + 6) / 2, ) .attr( 'y', dot.pos[1] * dotGap - (rectSize + 6) / 2, ) .attr('stroke', '#333') .attr('stroke-width', 1.5); // ✨ แก้ไข Backticks (`) สำหรับชื่อจังหวัดที่หายไป const fullDataName = dot.data.จังหวัด_เขต || ${d.name} เขต ${dot.districtNo}; const provinceName = fullDataName.split('_')[0]; const districtNo = dot.districtNo; const winnerParty = dot.data['เขตอันดับ 1'] || 'ไม่ทราบข้อมูล'; // แมปชื่อภาษาอังกฤษเป็นภาษาไทย const anomalyMap = { None: 'ปัญหาน้อย', Minor: 'ปัญหากลาง', Major: 'ปัญหามาก', }; const marginMap = { Marginal: 'สูสี', Moderate: 'กลาง', 'Safe-seat': 'ชนะขาด', }; const anomalyCol = currentElectionType === 'district' ? 'ความผิดปกติ(เขต)' : 'ความผิดปกติ(ปาร์ตี้ลิส)'; const marginCol = currentElectionType === 'district' ? 'คะแนนชนะขาด(เขต)' : 'คะแนนชนะขาด(ปาร์ตี้ลิส)'; const anomalyStatus = anomalyMap[dot.data[anomalyCol]] || 'ปัญหาน้อย'; const marginStatus = marginMap[dot.data[marginCol]] || 'สูสี'; // ✨ จัด Layout Tooltip ใหม่ให้อ่านง่ายขึ้น tooltip.style('visibility', 'visible').html(`