代码: 全选
以下是可直接執行的完整 HTML。Canvas 使用 $480 \times 540$ 的邏輯尺寸,並以 CSS 自動縮放至父元素寬度。
```html
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>點格多邊形</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background: #11151c;
font-family: Arial, "Microsoft JhengHei", sans-serif;
}
#ejpolygonarea {
width: min(100%, 720px);
}
#ejpolygonarea canvas {
display: block;
width: 100%;
height: auto;
aspect-ratio: 480 / 540;
border: 1px solid #3b4658;
border-radius: 8px;
background: #161b24;
box-shadow: 0 12px 32px rgb(0 0 0 / 35%);
touch-action: none;
cursor: crosshair;
}
</style>
</head>
<body>
<div id="ejpolygonarea"></div>
<script>
(() => {
"use strict";
const LOGICAL_WIDTH = 480;
const LOGICAL_HEIGHT = 540;
const GRID_HEIGHT = 480;
const OPTION_HEIGHT = 60;
const container = document.querySelector("#ejpolygonarea");
const canvas = document.createElement("canvas");
canvas.width = LOGICAL_WIDTH;
canvas.height = LOGICAL_HEIGHT;
canvas.setAttribute("aria-label", "點格多邊形繪圖區");
container.appendChild(canvas);
const ctx = canvas.getContext("2d");
const colors = {
gridBackground: "#151b25",
optionBackground: "#222a37",
optionBorder: "#3d485c",
gridLine: "rgba(255,255,255,0.045)",
point: "#8290a8",
pointShadow: "rgba(155,177,210,0.15)",
line: "#61dafb",
lineGlow: "rgba(97,218,251,0.22)",
startPoint: "#ffb74d",
currentPoint: "#ff5370",
selectedPoint: "#61dafb",
boundaryPoint: "#f7c65c",
interiorPoint: "#70e1a1",
polygonFill: "rgba(70,160,255,0.16)",
invalidLine: "#ff5370",
option: "#344055",
optionHover: "#42516b",
optionActive: "#2f94c7",
optionText: "#f4f7fb",
confirm: "#2eaf72",
download: "#397fd5"
};
let divisions = 10;
let gridPoints = [];
// 使用者按順序選取的控制點
let selectedPoints = [];
// 多邊形完成後的所有邊界格點與內部格點
let boundaryPoints = [];
let interiorPoints = [];
let polygonClosed = false;
let invalidSegment = null;
let invalidTimer = null;
let hoveredControl = null;
const optionControls = [];
const actionControls = [];
function pointKey(point) {
return `${point.col},${point.row}`;
}
function samePoint(a, b) {
return a && b && a.col === b.col && a.row === b.row;
}
function createGrid() {
gridPoints = [];
const step = GRID_HEIGHT / divisions;
// 排除四周邊界,因此使用 1 到 divisions - 1
for (let row = 1; row < divisions; row++) {
for (let col = 1; col < divisions; col++) {
gridPoints.push({
x: col * step,
y: row * step,
col,
row
});
}
}
}
function resetDrawing() {
selectedPoints = [];
boundaryPoints = [];
interiorPoints = [];
polygonClosed = false;
invalidSegment = null;
if (invalidTimer) {
clearTimeout(invalidTimer);
invalidTimer = null;
}
draw();
}
function setDivisions(value) {
divisions = value;
resetDrawing();
createGrid();
draw();
}
function roundedRectPath(x, y, width, height, radius) {
const r = Math.min(radius, width / 2, height / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + width - r, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + r);
ctx.lineTo(x + width, y + height - r);
ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
ctx.lineTo(x + r, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
function drawBackground() {
ctx.clearRect(0, 0, LOGICAL_WIDTH, LOGICAL_HEIGHT);
ctx.fillStyle = colors.gridBackground;
ctx.fillRect(0, 0, LOGICAL_WIDTH, GRID_HEIGHT);
ctx.fillStyle = colors.optionBackground;
ctx.fillRect(
0,
GRID_HEIGHT,
LOGICAL_WIDTH,
OPTION_HEIGHT
);
ctx.strokeStyle = colors.optionBorder;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, GRID_HEIGHT + 0.5);
ctx.lineTo(LOGICAL_WIDTH, GRID_HEIGHT + 0.5);
ctx.stroke();
}
function drawGridGuides() {
const step = GRID_HEIGHT / divisions;
ctx.save();
ctx.strokeStyle = colors.gridLine;
ctx.lineWidth = 1;
for (let i = 1; i < divisions; i++) {
const position = Math.round(i * step) + 0.5;
ctx.beginPath();
ctx.moveTo(position, 0);
ctx.lineTo(position, GRID_HEIGHT);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, position);
ctx.lineTo(LOGICAL_WIDTH, position);
ctx.stroke();
}
ctx.restore();
}
function drawCircle(point, radius, fill, stroke = null, lineWidth = 1) {
ctx.beginPath();
ctx.arc(point.x, point.y, radius, 0, Math.PI * 2);
ctx.fillStyle = fill;
ctx.fill();
if (stroke) {
ctx.strokeStyle = stroke;
ctx.lineWidth = lineWidth;
ctx.stroke();
}
}
function drawBasePoints() {
ctx.save();
for (const point of gridPoints) {
drawCircle(point, 2.3, colors.pointShadow);
drawCircle(point, 1.45, colors.point);
}
ctx.restore();
}
function drawPolygonFill() {
if (!polygonClosed || selectedPoints.length < 3) {
return;
}
ctx.save();
ctx.beginPath();
ctx.moveTo(selectedPoints[0].x, selectedPoints[0].y);
for (let i = 1; i < selectedPoints.length; i++) {
ctx.lineTo(selectedPoints[i].x, selectedPoints[i].y);
}
ctx.closePath();
ctx.fillStyle = colors.polygonFill;
ctx.fill();
ctx.restore();
}
function drawSegments() {
if (selectedPoints.length < 2) {
return;
}
ctx.save();
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.shadowColor = colors.lineGlow;
ctx.shadowBlur = 8;
ctx.strokeStyle = colors.line;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(selectedPoints[0].x, selectedPoints[0].y);
for (let i = 1; i < selectedPoints.length; i++) {
ctx.lineTo(selectedPoints[i].x, selectedPoints[i].y);
}
if (polygonClosed) {
ctx.closePath();
}
ctx.stroke();
ctx.restore();
}
function drawHighlightedPoints() {
if (polygonClosed) {
// 內部點
for (const point of interiorPoints) {
drawCircle(
point,
5,
colors.interiorPoint,
"#d9ffea",
1.2
);
}
// 邊界上所有格點,包括之前沒有手動點選的點
for (const point of boundaryPoints) {
drawCircle(
point,
5,
colors.boundaryPoint,
"#fff0bb",
1.2
);
}
} else {
for (let i = 0; i < selectedPoints.length; i++) {
const point = selectedPoints[i];
if (i === 0) {
drawCircle(
point,
6.5,
colors.startPoint,
"#fff2d8",
1.5
);
} else if (i === selectedPoints.length - 1) {
drawCircle(
point,
6.5,
colors.currentPoint,
"#ffe1e7",
1.5
);
} else {
drawCircle(
point,
5,
colors.selectedPoint,
"#dff8ff",
1.2
);
}
}
}
}
function drawInvalidSegment() {
if (!invalidSegment) {
return;
}
ctx.save();
ctx.beginPath();
ctx.setLineDash([8, 6]);
ctx.lineWidth = 3;
ctx.lineCap = "round";
ctx.strokeStyle = colors.invalidLine;
ctx.moveTo(invalidSegment.from.x, invalidSegment.from.y);
ctx.lineTo(invalidSegment.to.x, invalidSegment.to.y);
ctx.stroke();
ctx.restore();
}
function drawButton(control, active = false) {
const hovered = hoveredControl === control;
if (active) {
ctx.fillStyle = colors.optionActive;
} else if (hovered) {
ctx.fillStyle = colors.optionHover;
} else {
ctx.fillStyle = control.color || colors.option;
}
roundedRectPath(
control.x,
control.y,
control.width,
control.height,
7
);
ctx.fill();
ctx.strokeStyle = active
? "rgba(255,255,255,0.55)"
: "rgba(255,255,255,0.13)";
ctx.lineWidth = 1;
ctx.stroke();
ctx.fillStyle = colors.optionText;
ctx.font = control.font || "bold 15px Arial";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(
control.label,
control.x + control.width / 2,
control.y + control.height / 2 + 0.5
);
}
function drawOptions() {
optionControls.length = 0;
actionControls.length = 0;
if (!polygonClosed) {
const values = [10, 12, 16];
const buttonWidth = 108;
const buttonHeight = 38;
const gap = 18;
const totalWidth =
buttonWidth * values.length + gap * (values.length - 1);
const startX = (LOGICAL_WIDTH - totalWidth) / 2;
const y = GRID_HEIGHT + 11;
values.forEach((value, index) => {
const control = {
type: "division",
value,
label: `${value} × ${value}`,
x: startX + index * (buttonWidth + gap),
y,
width: buttonWidth,
height: buttonHeight
};
optionControls.push(control);
drawButton(control, divisions === value);
});
return;
}
const confirmControl = {
type: "confirm",
label: "✓",
x: 360,
y: GRID_HEIGHT + 11,
width: 45,
height: 38,
color: colors.confirm,
font: "bold 22px Arial"
};
const downloadControl = {
type: "download",
label: "↓ PNG",
x: 413,
y: GRID_HEIGHT + 11,
width: 58,
height: 38,
color: colors.download,
font: "bold 12px Arial"
};
actionControls.push(confirmControl, downloadControl);
ctx.fillStyle = "#f0f4fa";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.font = "12px Arial, Microsoft JhengHei";
ctx.fillText(
"Polygon area = interior points",
12,
GRID_HEIGHT + 20
);
ctx.fillText(
"+ boundary points ÷ 2 − 1",
12,
GRID_HEIGHT + 39
);
drawButton(confirmControl);
drawButton(downloadControl);
}
function draw() {
drawBackground();
drawGridGuides();
drawBasePoints();
drawPolygonFill();
drawSegments();
drawHighlightedPoints();
drawInvalidSegment();
drawOptions();
}
function getCanvasPosition(event) {
const rect = canvas.getBoundingClientRect();
return {
x: (event.clientX - rect.left) *
(LOGICAL_WIDTH / rect.width),
y: (event.clientY - rect.top) *
(LOGICAL_HEIGHT / rect.height)
};
}
function isInsideControl(position, control) {
return (
position.x >= control.x &&
position.x <= control.x + control.width &&
position.y >= control.y &&
position.y <= control.y + control.height
);
}
function getControlAt(position) {
const controls = polygonClosed
? actionControls
: optionControls;
return controls.find(control =>
isInsideControl(position, control)
) || null;
}
function nearestGridPoint(position) {
let nearest = null;
let nearestDistanceSquared = Infinity;
for (const point of gridPoints) {
const dx = point.x - position.x;
const dy = point.y - position.y;
const distanceSquared = dx * dx + dy * dy;
if (distanceSquared < nearestDistanceSquared) {
nearestDistanceSquared = distanceSquared;
nearest = point;
}
}
return nearest;
}
function orientation(a, b, c) {
const value =
(b.x - a.x) * (c.y - a.y) -
(b.y - a.y) * (c.x - a.x);
if (Math.abs(value) < 1e-9) {
return 0;
}
return value > 0 ? 1 : -1;
}
function onSegment(a, b, p) {
return (
orientation(a, b, p) === 0 &&
p.x >= Math.min(a.x, b.x) - 1e-9 &&
p.x <= Math.max(a.x, b.x) + 1e-9 &&
p.y >= Math.min(a.y, b.y) - 1e-9 &&
p.y <= Math.max(a.y, b.y) + 1e-9
);
}
function segmentsIntersect(a, b, c, d) {
const o1 = orientation(a, b, c);
const o2 = orientation(a, b, d);
const o3 = orientation(c, d, a);
const o4 = orientation(c, d, b);
if (o1 !== o2 && o3 !== o4) {
return true;
}
if (o1 === 0 && onSegment(a, b, c)) return true;
if (o2 === 0 && onSegment(a, b, d)) return true;
if (o3 === 0 && onSegment(c, d, a)) return true;
if (o4 === 0 && onSegment(c, d, b)) return true;
return false;
}
/*
* 檢查新線段是否和既有線段相交。
* 與相鄰線段共享端點不視為交叉。
*/
function wouldCrossExistingSegments(from, to, closing = false) {
const segmentCount = selectedPoints.length - 1;
for (let i = 0; i < segmentCount; i++) {
const a = selectedPoints[i];
const b = selectedPoints[i + 1];
// 新線段一定與最後一條線共用 from
if (i === segmentCount - 1) {
continue;
}
// 封閉線段會與第一條線共用起始點
if (closing && i === 0) {
continue;
}
if (segmentsIntersect(from, to, a, b)) {
return true;
}
}
return false;
}
function showInvalidSegment(from, to) {
invalidSegment = { from, to };
draw();
if (invalidTimer) {
clearTimeout(invalidTimer);
}
invalidTimer = setTimeout(() => {
invalidSegment = null;
invalidTimer = null;
draw();
}, 1000);
}
function allSelectedPointsCollinear() {
if (selectedPoints.length < 3) {
return true;
}
const a = selectedPoints[0];
const b = selectedPoints[1];
for (let i = 2; i < selectedPoints.length; i++) {
if (orientation(a, b, selectedPoints[i]) !== 0) {
return false;
}
}
return true;
}
function polygonSignedArea(points) {
let sum = 0;
for (let i = 0; i < points.length; i++) {
const next = (i + 1) % points.length;
sum +=
points[i].x * points[next].y -
points[next].x * points[i].y;
}
return sum / 2;
}
function pointInPolygon(point, polygon) {
let inside = false;
for (
let i = 0, j = polygon.length - 1;
i < polygon.length;
j = i++
) {
const a = polygon[i];
const b = polygon[j];
if (onSegment(a, b, point)) {
return false;
}
const intersects =
((a.y > point.y) !== (b.y > point.y)) &&
(
point.x <
((b.x - a.x) * (point.y - a.y)) /
(b.y - a.y) +
a.x
);
if (intersects) {
inside = !inside;
}
}
return inside;
}
function classifyPolygonPoints() {
boundaryPoints = [];
interiorPoints = [];
const boundaryKeys = new Set();
for (const point of gridPoints) {
for (let i = 0; i < selectedPoints.length; i++) {
const a = selectedPoints[i];
const b =
selectedPoints[(i + 1) % selectedPoints.length];
if (onSegment(a, b, point)) {
boundaryKeys.add(pointKey(point));
boundaryPoints.push(point);
break;
}
}
}
for (const point of gridPoints) {
if (boundaryKeys.has(pointKey(point))) {
continue;
}
if (pointInPolygon(point, selectedPoints)) {
interiorPoints.push(point);
}
}
}
function closePolygon() {
const first = selectedPoints[0];
const last = selectedPoints[selectedPoints.length - 1];
if (wouldCrossExistingSegments(last, first, true)) {
showInvalidSegment(last, first);
return;
}
// 防止零面積或所有點共線的圖形
if (Math.abs(polygonSignedArea(selectedPoints)) < 1e-9) {
resetDrawing();
return;
}
polygonClosed = true;
classifyPolygonPoints();
draw();
}
function handleGridClick(position) {
if (polygonClosed) {
return;
}
const point = nearestGridPoint(position);
if (!point) {
return;
}
if (selectedPoints.length === 0) {
selectedPoints.push(point);
draw();
return;
}
const existingIndex = selectedPoints.findIndex(
selected => samePoint(selected, point)
);
if (existingIndex !== -1) {
// 只有起始點,再次點擊起始點:移除起始點
if (
selectedPoints.length === 1 &&
existingIndex === 0
) {
resetDrawing();
return;
}
/*
* 多個點都在同一直線,點擊起始點:
* 移除所有點線。
*/
if (
existingIndex === 0 &&
allSelectedPointsCollinear()
) {
resetDrawing();
return;
}
/*
* 點擊起始點並且不是特殊情況:
* 嘗試封閉多邊形。
*/
if (
existingIndex === 0 &&
selectedPoints.length >= 3
) {
closePolygon();
return;
}
/*
* 點擊已經選取過的非起始點:
* 該點及其後面的點與線全部移除。
*/
selectedPoints = selectedPoints.slice(0, existingIndex);
draw();
return;
}
const last = selectedPoints[selectedPoints.length - 1];
if (wouldCrossExistingSegments(last, point, false)) {
showInvalidSegment(last, point);
return;
}
selectedPoints.push(point);
draw();
}
function pad2(number) {
return String(number).padStart(2, "0");
}
function createDownloadFilename() {
const date = new Date();
const yyyymmdd =
date.getFullYear() +
pad2(date.getMonth() + 1) +
pad2(date.getDate());
const hhiiss =
pad2(date.getHours()) +
pad2(date.getMinutes()) +
pad2(date.getSeconds());
return `ejpolygonarea_${yyyymmdd}_${hhiiss}.png`;
}
function downloadCanvas() {
/*
* 暫時取消按鈕 hover,避免把 hover 狀態下載進圖片。
*/
const previousHover = hoveredControl;
hoveredControl = null;
draw();
const filename = createDownloadFilename();
canvas.toBlob(blob => {
if (!blob) {
hoveredControl = previousHover;
draw();
return;
}
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
hoveredControl = previousHover;
draw();
}, "image/png");
}
function handleOptionClick(position) {
const control = getControlAt(position);
if (!control) {
return;
}
if (control.type === "division") {
setDivisions(control.value);
return;
}
if (control.type === "confirm") {
resetDrawing();
return;
}
if (control.type === "download") {
downloadCanvas();
}
}
canvas.addEventListener("pointerdown", event => {
if (event.button !== 0 && event.pointerType === "mouse") {
return;
}
event.preventDefault();
const position = getCanvasPosition(event);
if (position.y < GRID_HEIGHT) {
handleGridClick(position);
} else {
handleOptionClick(position);
}
});
canvas.addEventListener("pointermove", event => {
const position = getCanvasPosition(event);
const previous = hoveredControl;
hoveredControl =
position.y >= GRID_HEIGHT
? getControlAt(position)
: null;
canvas.style.cursor = hoveredControl
? "pointer"
: position.y < GRID_HEIGHT && !polygonClosed
? "crosshair"
: "default";
if (previous !== hoveredControl) {
draw();
}
});
canvas.addEventListener("pointerleave", () => {
if (hoveredControl) {
hoveredControl = null;
draw();
}
canvas.style.cursor = "default";
});
createGrid();
draw();
})();
</script>
</body>
</html>
```
功能包括:
- `10 × 10`、`12 × 12`、`16 × 16` 三種點格。
- 排除 Canvas 外圍邊界上的點。
- 自動選取最接近點擊位置的格點。
- 起始點、最後點、一般選取點分色顯示。
- 阻止線段自相交,違規線段以虛線顯示一秒。
- 點擊已選取點可移除該點及其後續路徑。
- 點擊起始點封閉多邊形。
- 自動標記多邊形邊界上的所有格點及內部格點。
- 完成後顯示 Pick 定理英文說明。
- 確認按鈕清空並恢復選項。
- 下載按鈕輸出帶日期時間檔名的 PNG。
- Canvas 透過 `width: 100%` 始終與父元素同寬。