🗺️ Make a map of Chiang Mai in JavaScript

A map looks complicated. It is not. A map is a list of numbers and one small function that turns those numbers into points on a screen. In nine steps you will build a real map application: real places, zoom in and out, drag to move, and tools to draw squares, circles and lines that tell you how many kilometres across they are. 地图看起来很复杂,其实不是。地图就是一串数字,加上一个把这些数字变成屏幕上的点的小函数。 九步之后你会做出一个真正的地图应用:真实地点、可缩放、可拖动,还有画方形、圆形和线的工具,并告诉你它们有多少公里。 แผนที่ดูซับซ้อน แต่จริง ๆ ไม่เลย แผนที่คือรายการตัวเลข กับฟังก์ชันเล็ก ๆ หนึ่งอันที่เปลี่ยนตัวเลขเป็นจุดบนจอ ในเก้าขั้นตอน คุณจะสร้างแอปแผนที่จริง: สถานที่จริง ซูมเข้าออก ลากเลื่อน และเครื่องมือวาดสี่เหลี่ยม วงกลม เส้น ที่บอกระยะเป็นกิโลเมตร

← JavaScript Lab ← Make a space game The same map in Python ▶ Use it now

No library. Most maps on the web use a library like Leaflet or Google Maps. We use nothing. That is on purpose: with a library you learn the library, and with forty lines of your own you learn how maps work. After this you will read Leaflet's documentation and recognise every idea in it. 不用库:网上大多数地图用 Leaflet 或 Google Maps 这样的库。我们什么都不用。 这是故意的:用库你学到的是库,用自己写的四十行你学到的是地图的原理。 学完这一课,你再去读 Leaflet 的文档,会发现每个概念你都认识。 ไม่ใช้ไลบรารี แผนที่บนเว็บส่วนใหญ่ใช้ไลบรารีอย่าง Leaflet หรือ Google Maps เราไม่ใช้อะไรเลย ตั้งใจแบบนั้น: ใช้ไลบรารีคุณจะได้เรียนไลบรารี แต่เขียนเองสี่สิบบรรทัดคุณจะเข้าใจว่าแผนที่ทำงานอย่างไร หลังจากนี้พออ่านเอกสารของ Leaflet คุณจะรู้จักทุกแนวคิดในนั้น

🎯 What you are making

Summary

Thirteen real places in Chiang Mai, the old city moat, and the Ping river. You can zoom, drag, and draw on top. Every shape you draw is stored as latitude and longitude, so it stays on the same street when you zoom. Lines and circles tell you the distance in kilometres.

摘要

清迈的十三个真实地点、古城护城河,还有滨河。可以缩放、拖动,并在上面画图。你画的每个图形都用经纬度保存,所以缩放时它还留在同一条街上。线和圆会告诉你多少公里。

สรุป

สถานที่จริงในเชียงใหม่สิบสามแห่ง คูเมืองเก่า และแม่น้ำปิง ซูมได้ ลากได้ และวาดทับได้ รูปที่คุณวาดถูกเก็บเป็นละติจูดกับลองจิจูด จึงอยู่ที่ถนนเดิมเมื่อซูม เส้นและวงกลมบอกระยะทางเป็นกิโลเมตร

a map of the Chiang Mai old city with coloured pins on temples, markets and gates
The same places, drawn by the Python version of this project. Yours will use the same coordinates — every one of them checked against OpenStreetMap. 同样的地点,由本项目的 Python 版本画出。你的版本会用同一批坐标——每一个都对照 OpenStreetMap 核对过。 สถานที่เดียวกัน วาดโดยเวอร์ชัน Python ของโปรเจกต์นี้ ของคุณจะใช้พิกัดชุดเดียวกัน — ตรวจกับ OpenStreetMap ทุกจุด

🧭 Nine steps

Same rules as before. One file called index.html, one <canvas>, one <script> at the bottom. Press ✓ done when a step works. 规则和上次一样:一个 index.html 文件、一块 <canvas>、 底部一个 <script>。一步做成功了就按 ✓ done กติกาเหมือนเดิม: ไฟล์ index.html หนึ่งไฟล์ <canvas> หนึ่งอัน <script> หนึ่งอันไว้ล่างสุด กด ✓ done เมื่อขั้นตอนสำเร็จ

1The page and the canvas

Make a folder called map and a file called index.html. This is the same skeleton as the space game. If you built that, you can copy it and change the title. 新建一个文件夹叫 map,里面一个 index.html。 骨架和太空游戏一样。做过那个的话,直接复制过来改标题就行。 สร้างโฟลเดอร์ชื่อ map และไฟล์ index.html โครงเหมือนเกมอวกาศ ถ้าทำอันนั้นแล้ว คัดลอกมาเปลี่ยนหัวเรื่องได้เลย

index.html
<!DOCTYPE html> <html> <head><meta charset="UTF-8"><title>My Chiang Mai Map</title></head> <body style="background:#0a0d18;color:#eaf0ff;font-family:system-ui"> <h1>🗺️ My Chiang Mai Map</h1> <canvas id="map" width="720" height="480"></canvas> <script> const canvas = document.getElementById("map"); const ctx = canvas.getContext("2d"); const W = canvas.width, H = canvas.height; ctx.fillStyle = "#0d1424"; ctx.fillRect(0, 0, W, H); </script> </body> </html>
Keep charset="UTF-8". Without it, Thai names like วัดพระสิงห์ turn into strange boxes. This one line is why the page can hold three languages at once. 一定要保留 charset="UTF-8"没有它,วัดพระสิงห์ 这样的泰文会变成奇怪的方块。 就是这一行让页面能同时装下三种语言。 อย่าลบ charset="UTF-8" ถ้าไม่มี ชื่อไทยอย่าง วัดพระสิงห์ จะกลายเป็นกล่องประหลาด บรรทัดเดียวนี้แหละที่ทำให้หน้าเว็บเก็บสามภาษาพร้อมกันได้

2What the numbers mean

Every place on Earth has two numbers. Latitude says how far north. Longitude says how far east. Chiang Mai is about 18.79 north and 98.99 east. 地球上每个地方都有两个数字。纬度说往北多远,经度说往东多远。 清迈大约是北纬 18.79、东经 98.99。 ทุกที่บนโลกมีตัวเลขสองตัว ละติจูดบอกว่าเหนือแค่ไหน ลองจิจูดบอกว่าตะวันออกแค่ไหน เชียงใหม่อยู่ประมาณ 18.79 เหนือ และ 98.99 ตะวันออก

placelatitudelongitude
Tha Phae Gate18.7877698.99327
Wat Don Chan18.7602199.03257
Chiang Mai Airport18.7668498.96488
Beijing 北京39.90571116.39130
Denver, USA39.74194−105.07103
Read the last row again. Denver's longitude is negative. Numbers go up as you travel east and down as you travel west, so everywhere in the Americas has a minus sign. Your code will handle that with no extra work — a minus number is just a number. 再看最后一行:丹佛的经度是负数。往东数字变大,往西变小, 所以整个美洲都带负号。你的代码不用为此多写一行——负数也只是数字。 อ่านแถวสุดท้ายอีกครั้ง ลองจิจูดของเดนเวอร์เป็นลบ ตัวเลขเพิ่มขึ้นเมื่อไปทางตะวันออก และลดลงเมื่อไปทางตะวันตก ทวีปอเมริกาจึงติดลบทั้งหมด โค้ดของคุณรับมือได้โดยไม่ต้องเขียนเพิ่ม — เลขติดลบก็คือเลข

Two more useful facts. One degree of latitude is always about 111 kilometres. One degree of longitude is 111 km at the equator but gets shorter as you go north, because the lines meet at the poles. In Chiang Mai it is about 105 km. 还有两个有用的事实:一度纬度总是约 111 公里。一度经度在赤道是 111 公里, 越往北越短,因为经线在两极汇合。在清迈大约是 105 公里。 อีกสองข้อที่มีประโยชน์: หนึ่งองศาละติจูดยาวประมาณ 111 กิโลเมตร เสมอ ส่วนหนึ่งองศาลองจิจูดยาว 111 กม. ที่เส้นศูนย์สูตร แต่สั้นลงเมื่อขึ้นเหนือ เพราะเส้นบรรจบกันที่ขั้วโลก ที่เชียงใหม่ยาวประมาณ 105 กม.

3The heart: turn a place into a point

This is the whole map, in two functions. toScreen turns a place into x and y. toMap turns x and y back into a place. Everything else in this tutorial only uses these two. 整张地图就在这两个函数里。toScreen 把地点变成 x 和 y, toMap 把 x 和 y 变回地点。这个教程剩下的部分只用这两个函数。 ทั้งแผนที่อยู่ในสองฟังก์ชันนี้ toScreen เปลี่ยนสถานที่เป็น x และ y toMap เปลี่ยน x และ y กลับเป็นสถานที่ ที่เหลือทั้งบทเรียนใช้แค่สองอันนี้

const HOME = { lat: 18.7883, lon: 98.9853, zoom: 1 }; let view = { ...HOME }; const PPD = 3600; // pixels per degree at zoom 1 // Longitude is squeezed as you go north. cos() is the squeeze. function ppdX() { return PPD * view.zoom * Math.cos(view.lat * Math.PI / 180); } function ppdY() { return PPD * view.zoom; } function toScreen(lat, lon) { return { x: (lon - view.lon) * ppdX() + W / 2, y: (view.lat - lat) * ppdY() + H / 2 }; } function toMap(x, y) { return { lon: (x - W / 2) / ppdX() + view.lon, lat: view.lat - (y - H / 2) / ppdY() }; }
Why the y line is backwards. On a map, north is up. On a canvas, y gets bigger as you go down. So latitude has to be flipped: view.lat - lat, not lat - view.lat. Get this the wrong way round and your city appears upside down — a beautiful bug, and a very common one. 为什么 y 那行是反的:地图上北在上;画布上 y 越往下越大。 所以纬度要翻过来:写 view.lat - lat,不是 lat - view.lat。 写反了城市就上下颠倒——很漂亮的 bug,也非常常见。 ทำไมบรรทัด y ถึงกลับด้าน บนแผนที่ ทิศเหนืออยู่ข้างบน แต่บนแคนวาส y เพิ่มขึ้นเมื่อลงล่าง ละติจูดจึงต้องกลับด้าน: view.lat - lat ไม่ใช่ lat - view.lat ถ้าเขียนกลับกัน เมืองจะกลับหัว — บั๊กที่สวยงามและพบบ่อยมาก
Test it before you go on. Put a place in and get x and y out, then put that x and y back in. You must get the same place. Type this in the console (F12): toMap(toScreen(18.78776, 98.99327).x, toScreen(18.78776, 98.99327).y) 继续之前先测一下:放一个地点进去得到 x、y,再把这个 x、y 放回去, 必须得到同一个地点。在控制台(F12)里输入上面那行。 ทดสอบก่อนไปต่อ ใส่สถานที่เข้าไปให้ได้ x กับ y แล้วใส่ x, y กลับเข้าไปใหม่ ต้องได้สถานที่เดิม พิมพ์บรรทัดข้างบนในคอนโซล (F12)

4The places — an array of objects

Now the data. An array of objects: many places, and each place is one thing with parts. Then one loop draws them all. 现在是数据:一个数组装很多对象——很多地点,每个地点是一个有各部分的东西。 然后一个循环把它们全画出来。 ทีนี้คือข้อมูล: อาร์เรย์ของอ็อบเจกต์ — หลายสถานที่ และแต่ละที่คือสิ่งเดียวที่มีหลายส่วน แล้วลูปเดียววาดทั้งหมด

const PLACES = [ { name: "Wat Phra Singh", thai: "วัดพระสิงห์", lat: 18.78878, lon: 98.98156, kind: "temple" }, { name: "Tha Phae Gate", thai: "ประตูท่าแพ", lat: 18.78776, lon: 98.99327, kind: "landmark" }, { name: "Warorot Market", thai: "ตลาดวโรรส", lat: 18.79024, lon: 99.00053, kind: "market" }, { name: "Wat Don Chan", thai: "วัดดอนจั่น", lat: 18.76021, lon: 99.03257, kind: "temple" }, { name: "Airport", thai: "ท่าอากาศยาน", lat: 18.76684, lon: 98.96488, kind: "travel" } ]; const COLOURS = { temple: "#a678ff", market: "#ff9f45", travel: "#57a8ff", landmark: "#ff5d6c", school: "#3ddc84", nature: "#2fbf71" }; function drawPlaces() { PLACES.forEach(p => { const s = toScreen(p.lat, p.lon); ctx.fillStyle = COLOURS[p.kind]; ctx.beginPath(); ctx.arc(s.x, s.y, 6, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "#eaf0ff"; ctx.font = "700 12px system-ui"; ctx.fillText(p.name, s.x + 10, s.y + 4); }); }
The colour comes from the data, not from an if. COLOURS[p.kind] looks up the colour by the word in the object. Adding a new kind of place means adding one line to COLOURS — you never touch the drawing code again. 颜色来自数据,不是来自 if:COLOURS[p.kind] 用对象里的那个词去查颜色。 以后加一种新地点,只要在 COLOURS 里加一行,绘图代码永远不用再动。 สีมาจากข้อมูล ไม่ใช่จาก if COLOURS[p.kind] ค้นสีจากคำในอ็อบเจกต์ เพิ่มประเภทสถานที่ใหม่ก็แค่เพิ่มหนึ่งบรรทัดใน COLOURS ไม่ต้องแตะโค้ดวาดอีกเลย

5The moat and the river — lists of points

A line on a map is just an array of places joined in order. The old city moat is four corners joined into a square. The Ping river is seven points joined into a curve. 地图上的线只是一串按顺序连起来的地点。古城护城河是四个角连成方形, 滨河是七个点连成一条曲线。 เส้นบนแผนที่คืออาร์เรย์ของสถานที่ที่ต่อกันตามลำดับ คูเมืองเก่าคือสี่มุมต่อเป็นสี่เหลี่ยม แม่น้ำปิงคือเจ็ดจุดต่อเป็นเส้นโค้ง

const MOAT = [ { lat: 18.7949, lon: 98.9791 }, { lat: 18.7949, lon: 98.9933 }, { lat: 18.7807, lon: 98.9933 }, { lat: 18.7807, lon: 98.9791 } ]; function line(points, colour, width, close) { ctx.strokeStyle = colour; ctx.lineWidth = width; ctx.beginPath(); points.forEach((p, i) => { const s = toScreen(p.lat, p.lon); i === 0 ? ctx.moveTo(s.x, s.y) : ctx.lineTo(s.x, s.y); }); if (close) ctx.closePath(); ctx.stroke(); } line(MOAT, "#b03030", 3, true); // closed = a square
moveTo once, lineTo many times. moveTo lifts the pen and puts it down somewhere. lineTo drags it. The i === 0 test is what decides which one — one line of code that turns any list of places into a road, a river or a wall. moveTo 一次,lineTo 很多次:moveTo 是抬笔落到某处, lineTo 是拖着走。i === 0 决定用哪个——这一行代码就能把任何地点列表变成路、河或城墙。 moveTo ครั้งเดียว lineTo หลายครั้ง moveTo คือยกปากกาไปวางที่จุดหนึ่ง lineTo คือลากไป การตรวจ i === 0 เป็นตัวตัดสิน — โค้ดบรรทัดเดียวเปลี่ยนลิสต์สถานที่ใด ๆ ให้เป็นถนน แม่น้ำ หรือกำแพง
Say what is not exact. The moat corners are close, not perfect, and the river was traced by hand. A real map application says so. Data that pretends to be exact is worse than data that admits it is rough. 说清楚哪里不精确:护城河的角是大致位置,河是手描的。 真正的地图应用会说明这一点。假装精确的数据,比承认粗糙的数据更糟。 บอกด้วยว่าอะไรไม่แม่น มุมคูเมืองเป็นค่าประมาณ และแม่น้ำวาดด้วยมือ แอปแผนที่จริงจะบอกแบบนี้ ข้อมูลที่แกล้งทำเป็นแม่นยำ แย่กว่าข้อมูลที่ยอมรับว่าหยาบ

6Zoom — one number changes everything

You already wrote zoom. It is view.zoom, sitting inside ppdX and ppdY. Change that one number, draw again, and the whole map grows. Nothing else needs touching. 缩放你已经写好了——就是 ppdXppdY 里的 view.zoom。 改这一个数字、重画一遍,整张地图就变大了。别的什么都不用动。 คุณเขียนซูมไว้แล้ว มันคือ view.zoom ที่อยู่ใน ppdX และ ppdY เปลี่ยนตัวเลขตัวเดียว วาดใหม่ แล้วทั้งแผนที่ก็ใหญ่ขึ้น ไม่ต้องแก้อะไรอีก

function zoomBy(f) { view.zoom = Math.min(24, Math.max(0.25, view.zoom * f)); draw(); } // buttons: onclick="zoomBy(1.5)" and onclick="zoomBy(1/1.5)" // The mouse wheel, done properly: keep the place under the pointer // exactly under the pointer while the zoom changes. canvas.addEventListener("wheel", e => { e.preventDefault(); const p = spot(e); const before = toMap(p.x, p.y); view.zoom = Math.min(24, Math.max(0.25, view.zoom * (e.deltaY < 0 ? 1.15 : 1 / 1.15))); const after = toMap(p.x, p.y); view.lon += before.lon - after.lon; view.lat += before.lat - after.lat; draw(); }, { passive: false });
Multiply, never add. zoom * 1.5 feels the same at every level. zoom + 1 would be a giant jump when you are zoomed out and almost nothing when you are zoomed in. Zoom is multiplication — every map app in the world works this way. 用乘,不要用加:zoom * 1.5 在任何级别手感都一样; zoom + 1 在缩到很小时是一大跳,在放到很大时几乎没变化。缩放是乘法—— 全世界的地图应用都这么做。 ใช้คูณ ไม่ใช่บวก zoom * 1.5 ให้ความรู้สึกเหมือนกันทุกระดับ ส่วน zoom + 1 จะกระโดดมากตอนซูมออก และแทบไม่ขยับตอนซูมเข้า การซูมคือการคูณ — แอปแผนที่ทั้งโลกทำแบบนี้

7Drag the map with your finger or mouse

Three events: press, move, let go. Remember where the press started, then move the view by the difference. One set of listeners covers the mouse and the finger, because we use pointer events. 三个事件:按下、移动、松开。记住按下的位置,然后按差值移动视图。 因为用的是 pointer 事件,一套监听器同时管鼠标手指。 สามอีเวนต์: กด เลื่อน ปล่อย จำตำแหน่งที่กดไว้ แล้วเลื่อนมุมมองตามผลต่าง ชุดเดียวใช้ได้ทั้งเมาส์และนิ้ว เพราะเราใช้ pointer events

// The canvas can be shown smaller than it really is, so turn the // page position into a real canvas position first. function spot(e) { const r = canvas.getBoundingClientRect(); return { x: (e.clientX - r.left) * (W / r.width), y: (e.clientY - r.top) * (H / r.height) }; } let panFrom = null; canvas.addEventListener("pointerdown", e => { const p = spot(e); canvas.setPointerCapture(e.pointerId); panFrom = { x: p.x, y: p.y, lat: view.lat, lon: view.lon }; }); canvas.addEventListener("pointermove", e => { if (!panFrom) return; const p = spot(e); view.lon = panFrom.lon - (p.x - panFrom.x) / ppdX(); view.lat = panFrom.lat + (p.y - panFrom.y) / ppdY(); draw(); }); canvas.addEventListener("pointerup", () => { panFrom = null; });
Add touch-action: none to the canvas in your CSS. Without it, dragging on a phone scrolls the page instead of moving the map, and the map feels broken. One CSS line fixes it. 在 CSS 里给画布加 touch-action: none 没有它,在手机上拖动会滚动页面而不是移动地图,地图会像坏了一样。一行 CSS 就解决。 ใส่ touch-action: none ให้แคนวาสใน CSS ถ้าไม่มี การลากบนมือถือจะเลื่อนหน้าเว็บแทนที่จะเลื่อนแผนที่ แล้วแผนที่จะเหมือนเสีย แก้ด้วย CSS บรรทัดเดียว

8The drawing tools: line, square, circle

Now the part you asked for. One variable remembers which tool is chosen. Press starts a shape, move changes it, let go saves it into the shapes array. 现在是重点部分。一个变量记住选了哪个工具:按下开始一个图形,移动改变它,松开把它存进 shapes 数组。 มาถึงส่วนที่รอ ตัวแปรหนึ่งตัวจำว่าเลือกเครื่องมือไหน กดเริ่มรูป เลื่อนเปลี่ยนรูป ปล่อยแล้วเก็บลงอาร์เรย์ shapes

let shapes = []; // everything you have drawn let tool = "pan"; // "pan" | "line" | "rect" | "circle" let drawing = null; // the shape being made right now // inside pointerdown, instead of always panning: if (tool === "pan") { panFrom = { x: p.x, y: p.y, lat: view.lat, lon: view.lon }; } else { const m = toMap(p.x, p.y); drawing = { type: tool, a: m, b: m }; } // inside pointermove: if (drawing) { drawing.b = toMap(p.x, p.y); draw(); } // inside pointerup: if (drawing) { shapes.push(drawing); drawing = null; }
This is the most important idea on the page. Look at what is stored: a and b are latitude and longitude, not x and y. So when you zoom or drag, the shape is drawn again from its real place on Earth and stays on the same street. Store pixels instead and every shape slides off the map the moment you zoom. 这是整页最重要的概念。看看存的是什么:ab经纬度, 不是 x、y。所以缩放或拖动时,图形会按它在地球上的真实位置重画,还留在同一条街上。 如果存的是像素,一缩放所有图形就都滑走了。 นี่คือแนวคิดสำคัญที่สุดในหน้านี้ ดูว่าเก็บอะไรไว้: a และ b คือละติจูดกับลองจิจูด ไม่ใช่ x กับ y เวลาซูมหรือลาก รูปจึงถูกวาดใหม่จากตำแหน่งจริงบนโลกและอยู่ที่ถนนเดิม ถ้าเก็บเป็นพิกเซล รูปทุกอันจะเลื่อนหลุดทันทีที่ซูม
function drawShape(sh) { const a = toScreen(sh.a.lat, sh.a.lon); const b = toScreen(sh.b.lat, sh.b.lon); ctx.strokeStyle = "#3ddc84"; ctx.fillStyle = "rgba(61,220,132,.10)"; ctx.lineWidth = 2; if (sh.type === "line") { ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); } else if (sh.type === "rect") { const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y); const w = Math.abs(b.x - a.x), h = Math.abs(b.y - a.y); ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); } else { const r = Math.hypot(b.x - a.x, b.y - a.y); ctx.beginPath(); ctx.arc(a.x, a.y, r, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } }
Why Math.min and Math.abs for the square? Because people drag in all four directions. Drag up and to the left and the width would be a negative number. Two small functions make every direction work. 方形为什么要用 Math.minMath.abs 因为人会往四个方向拖。往左上拖,宽度就成了负数。两个小函数让四个方向都能用。 ทำไมสี่เหลี่ยมต้องใช้ Math.min กับ Math.abs เพราะคนลากได้ทั้งสี่ทิศ ลากขึ้นไปทางซ้ายแล้วความกว้างจะติดลบ ฟังก์ชันเล็ก ๆ สองตัวทำให้ทุกทิศใช้ได้

9Real kilometres, and saving your work

The earth is round, so you cannot just subtract the numbers. This is the haversine formula. 6371 is the radius of the earth in kilometres. It gives the straight line, like a bird flies — the road is always longer. 地球是圆的,所以不能直接相减。这是 haversine(半正矢)公式, 6371 是地球半径(公里)。它算的是直线距离,像鸟飞的那样——走路的距离总是更长。 โลกกลม จึงลบตัวเลขตรง ๆ ไม่ได้ นี่คือสูตร haversine ส่วน 6371 คือรัศมีโลกเป็นกิโลเมตร มันให้ระยะเส้นตรงอย่างที่นกบิน — ระยะถนนยาวกว่าเสมอ

function distanceKm(a, b) { const R = 6371; const toRad = d => d * Math.PI / 180; const dLat = toRad(b.lat - a.lat), dLon = toRad(b.lon - a.lon); const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(h)); } // Wat Phra Singh to Wat Chedi Luang = 0.58 km. Walk it and check. // Keep your drawings after you close the page: function save() { localStorage.setItem("cm_map_shapes", JSON.stringify(shapes)); } shapes = JSON.parse(localStorage.getItem("cm_map_shapes") || "[]");
Check your program against the real world. The formula says Wat Phra Singh to Wat Chedi Luang is 0.58 km. That is a seven minute walk, and it is right. A program that agrees with the street outside is a program you can trust. 拿真实世界检验你的程序:公式说帕辛寺到契迪銮寺是 0.58 公里, 走路七分钟,是对的。能和门外那条街对上的程序,才是可以信任的程序。 ตรวจโปรแกรมกับโลกจริง สูตรบอกว่าวัดพระสิงห์ถึงวัดเจดีย์หลวงคือ 0.58 กม. เดินเจ็ดนาที และมันถูกต้อง โปรแกรมที่ตรงกับถนนข้างนอกคือโปรแกรมที่เชื่อถือได้
localStorage only holds text. That is why JSON.stringify turns your array into text on the way in, and JSON.parse turns it back on the way out. Forget one of them and you will save the words [object Object] for ever. localStorage 只能存文本:所以存进去时用 JSON.stringify 把数组变成文本, 取出来时用 JSON.parse 变回来。少写一个,你就会永远存着 [object Object] 这几个字。 localStorage เก็บได้แต่ข้อความ จึงต้องใช้ JSON.stringify แปลงอาร์เรย์เป็นข้อความตอนเก็บ และ JSON.parse แปลงกลับตอนอ่าน ลืมอันใดอันหนึ่งแล้วคุณจะเก็บคำว่า [object Object] ไว้ตลอดกาล

▶ Use the finished map

Summary

This is the map from the nine steps, running on this page. Choose a tool, then drag on the map. Move pans the map, and the other three draw. Use the wheel or the buttons to zoom. Your shapes are saved on this device, so they are still here tomorrow.

摘要

这就是九个步骤做出来的地图,正跑在这一页上。先选工具,再在地图上拖动:Move 是平移,另外三个是绘图。用滚轮或按钮缩放。你画的图形保存在这台设备上,明天还在。

สรุป

นี่คือแผนที่จากเก้าขั้นตอน กำลังทำงานบนหน้านี้ เลือกเครื่องมือแล้วลากบนแผนที่ Move คือเลื่อนแผนที่ อีกสามอันคือวาด ใช้ล้อเมาส์หรือปุ่มเพื่อซูม รูปที่วาดถูกบันทึกไว้ในเครื่องนี้ พรุ่งนี้ก็ยังอยู่

move the pointer over the map to read its latitude and longitude
What is drawn here. The red square is the old city moat, the blue line is the Ping river, and the coloured dots are the thirteen places. The moat corners and the river are close but not exact; the thirteen places were checked against OpenStreetMap. 图上画了什么:红色方框是古城护城河,蓝线是滨河,彩色圆点是十三个地点。 护城河的角和河流是近似的;十三个地点都对照 OpenStreetMap 核对过。 บนแผนที่มีอะไร สี่เหลี่ยมแดงคือคูเมืองเก่า เส้นน้ำเงินคือแม่น้ำปิง จุดสีคือสถานที่สิบสามแห่ง มุมคูเมืองและแม่น้ำเป็นค่าประมาณ ส่วนสิบสามสถานที่ตรวจกับ OpenStreetMap แล้ว

🛠️ Now make it yours

Five changes, in order of difficulty. The first one takes one minute. 五个改动,按难度排序。第一个只要一分钟。 ห้าการเปลี่ยนแปลง เรียงตามความยาก อันแรกใช้เวลาหนึ่งนาที

  • Add your own house. Open a map site, right-click your street, copy the two numbers, and add one line to PLACES. 加上你家:打开地图网站,右键点你家那条街,复制那两个数字,在 PLACES 里加一行。 เพิ่มบ้านคุณเอง: เปิดเว็บแผนที่ คลิกขวาที่ถนนบ้านคุณ คัดลอกตัวเลขสองตัว แล้วเพิ่มหนึ่งบรรทัดใน PLACES
  • Show the Thai name too. Every place object already has thai. One more fillText line under the English one. 同时显示泰文名:每个地点对象里已经有 thai 了。在英文那行下面再加一行 fillText แสดงชื่อไทยด้วย: อ็อบเจกต์สถานที่มี thai อยู่แล้ว เพิ่ม fillText อีกบรรทัดใต้ชื่ออังกฤษ
  • Colour the shape you are drawing. Add a colour to the shape object when you make it, and read it in drawShape. 给图形上色:创建图形对象时加一个 colour,在 drawShape 里读出来。 ใส่สีให้รูปที่วาด: เพิ่ม colour ตอนสร้างอ็อบเจกต์รูป แล้วอ่านค่าใน drawShape
  • Click a pin to see its name. On pointerdown, loop through PLACES and find the one whose screen point is nearest the click. 点击图钉显示名字:在 pointerdown 里遍历 PLACES, 找到屏幕位置离点击最近的那个。 คลิกหมุดเพื่อดูชื่อ: ใน pointerdown วนลูป PLACES หาอันที่จุดบนจออยู่ใกล้จุดคลิกที่สุด
  • Draw a walk between three temples. Take the moat code, put in three place names, and add up distanceKm between each pair. That is exactly what the Python version does. 画一条三座寺庙的步行路线:拿护城河那段代码,换成三个地点名, 再把每一对之间的 distanceKm 加起来。Python 版做的正是这件事。 วาดเส้นทางเดินสามวัด: เอาโค้ดคูเมืองมา ใส่ชื่อสถานที่สามแห่ง แล้วบวก distanceKm ของแต่ละคู่ เวอร์ชัน Python ทำแบบนี้เป๊ะ ๆ
🐍 See the same map in Python. Same coordinates, same haversine, but drawn with a library called folium. Comparing the two is the fastest way to see which parts are programming and which parts are only JavaScript.
看看同一张地图的 Python 版:同样的坐标、同样的 haversine, 但用一个叫 folium 的库来画。对比两版,是分清哪些是编程、哪些只是 JavaScript 的最快办法。

ดูแผนที่เดียวกันในเวอร์ชัน Python: พิกัดเดียวกัน haversine เดียวกัน แต่วาดด้วยไลบรารีชื่อ folium เทียบสองอันคือวิธีเร็วที่สุดที่จะเห็นว่าส่วนไหนคือการเขียนโปรแกรม และส่วนไหนเป็นแค่ JavaScript
Open ▸

📌 Points to remember

  • A map is two numbers and one function. toScreen is the whole idea; everything else uses it. 地图 = 两个数字 + 一个函数:toScreen 就是全部思路,其他都在用它。 แผนที่คือตัวเลขสองตัวกับฟังก์ชันเดียว toScreen คือแนวคิดทั้งหมด ที่เหลือใช้มัน
  • North is up, but y goes down. Latitude must be flipped, or your city is upside down. 北在上,但 y 往下增大:纬度必须翻转,否则城市会颠倒。 เหนืออยู่บน แต่ y เพิ่มลงล่าง ต้องกลับด้านละติจูด ไม่งั้นเมืองจะกลับหัว
  • Store places, not pixels. Shapes saved in latitude and longitude survive zooming and dragging. 存地点,别存像素:用经纬度保存的图形,缩放和拖动后依然正确。 เก็บสถานที่ ไม่ใช่พิกเซล รูปที่เก็บเป็นละติจูด/ลองจิจูดจะรอดจากการซูมและการลาก
  • Zoom multiplies. zoom * 1.5, never zoom + 1. 缩放用乘法:zoom * 1.5,绝不是 zoom + 1 การซูมคือการคูณ zoom * 1.5 ไม่ใช่ zoom + 1
  • The earth is round, so use haversine. Subtracting the numbers gives the wrong answer. 地球是圆的,所以用 haversine:直接相减会算错。 โลกกลม จึงต้องใช้ haversine การลบตัวเลขตรง ๆ ให้คำตอบผิด
  • Say which data is rough. Honest maps beat pretty maps. 说清楚哪些数据是粗略的:诚实的地图胜过漂亮的地图。 บอกด้วยว่าข้อมูลไหนหยาบ แผนที่ที่ซื่อสัตย์ดีกว่าแผนที่ที่สวย

Thank you for mapping your city with us. Put your own house on it tonight — that is when it becomes yours. See you in the next tutorial. 感谢和我们一起绘制你的城市。今晚就把你家加上去——那一刻它才真正属于你。下个教程见。 ขอบคุณที่ทำแผนที่เมืองของคุณไปด้วยกัน คืนนี้ลองใส่บ้านคุณลงไป — ตอนนั้นแหละมันถึงเป็นของคุณจริง ๆ แล้วเจอกันบทเรียนหน้า