diff --git a/packages/web/src/asset/arrow-line/arrow-tile.svg b/packages/web/src/asset/arrow-line/arrow-tile.svg
new file mode 100644
index 0000000..8de39d5
--- /dev/null
+++ b/packages/web/src/asset/arrow-line/arrow-tile.svg
@@ -0,0 +1,10 @@
+
diff --git a/packages/web/src/component/ArrowLineCanvas.tsx b/packages/web/src/component/ArrowLineCanvas.tsx
new file mode 100644
index 0000000..ffe507b
--- /dev/null
+++ b/packages/web/src/component/ArrowLineCanvas.tsx
@@ -0,0 +1,140 @@
+import styled from "@emotion/styled";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { alphaColor, percent, px } from "~/common/css-util";
+import { type Point, drawArrowLine } from "~/feature/arrow-line-canvas";
+import MockActionButton from "~/component/MockActionButton";
+import { MockRangeFormRow } from "~/component/mock-form-ui";
+
+const CANVAS_WIDTH = 960;
+const CANVAS_HEIGHT = 540;
+const ARROW_WIDTH = 56;
+const ARROW_THICKNESS = 28;
+const ARROW_COLOR = "#d1373b";
+const BASE_FILL_COLOR = alphaColor(ARROW_COLOR, 0.5);
+const SPACING_MIN = 1;
+const SPACING_MAX = 5;
+const SPACING_STEP = 0.1;
+const SPACING_DEFAULT = 2;
+const POINT_RADIUS = 4;
+const POINT_COLOR = "#333333";
+
+const Wrapper = styled.div({
+ display: "flex",
+ flexDirection: "column",
+ gap: px(8)
+});
+
+const Toolbar = styled.div({
+ display: "flex",
+ gap: px(8)
+});
+
+const StyledCanvas = styled.canvas({
+ display: "block",
+ width: percent(100),
+ aspectRatio: `${CANVAS_WIDTH} / ${CANVAS_HEIGHT}`,
+ border: "1px solid #cccccc",
+ cursor: "crosshair",
+ touchAction: "none"
+});
+
+const getCanvasPoint = (
+ canvas: HTMLCanvasElement,
+ event: { clientX: number; clientY: number }
+): Point => {
+ const rect = canvas.getBoundingClientRect();
+ return {
+ x: ((event.clientX - rect.left) / rect.width) * canvas.width,
+ y: ((event.clientY - rect.top) / rect.height) * canvas.height
+ };
+};
+
+const ArrowLineCanvas = ({ arrowImageSrc }: { arrowImageSrc: string }) => {
+ const canvasRef = useRef(null);
+ const imageRef = useRef(null);
+ const [points, setPoints] = useState([]);
+ const [isImageLoaded, setIsImageLoaded] = useState(false);
+ const [spacingLevel, setSpacingLevel] = useState(SPACING_DEFAULT);
+
+ useEffect(() => {
+ const image = new Image();
+ image.onload = () => setIsImageLoaded(true);
+ image.src = arrowImageSrc;
+ imageRef.current = image;
+ return () => setIsImageLoaded(false);
+ }, [arrowImageSrc]);
+
+ const drawOptions = useMemo(
+ () => ({
+ arrowWidth: ARROW_WIDTH,
+ arrowStep: ARROW_WIDTH * spacingLevel,
+ tileThickness: ARROW_THICKNESS,
+ baseFillColor: BASE_FILL_COLOR,
+ pointRadius: POINT_RADIUS,
+ pointColor: POINT_COLOR
+ }),
+ [spacingLevel]
+ );
+
+ const redraw = useCallback(() => {
+ const canvas = canvasRef.current;
+ const image = imageRef.current;
+ const ctx = canvas?.getContext("2d");
+ if (!canvas || !image || !ctx || !isImageLoaded) {
+ return;
+ }
+ drawArrowLine(ctx, image, points, drawOptions);
+ }, [points, isImageLoaded, drawOptions]);
+
+ useEffect(() => {
+ redraw();
+ }, [redraw]);
+
+ const handleClick = (event: { clientX: number; clientY: number }) => {
+ const canvas = canvasRef.current;
+ if (!canvas) {
+ return;
+ }
+ setPoints(list => [...list, getCanvasPoint(canvas, event)]);
+ };
+
+ return (
+
+
+ setPoints(list => list.slice(0, -1)) }
+ : null
+ }
+ >
+ ひとつ戻す
+
+ setPoints([]) } : null
+ }
+ >
+ クリア
+
+
+
+
+
+ );
+};
+
+export default ArrowLineCanvas;
diff --git a/packages/web/src/feature/arrow-line-canvas.ts b/packages/web/src/feature/arrow-line-canvas.ts
new file mode 100644
index 0000000..34d2e07
--- /dev/null
+++ b/packages/web/src/feature/arrow-line-canvas.ts
@@ -0,0 +1,93 @@
+export type Point = { x: number; y: number };
+
+export type ArrowLineOptions = {
+ arrowWidth: number;
+ arrowStep: number;
+ tileThickness: number;
+ baseFillColor: string;
+ pointRadius: number;
+ pointColor: string;
+};
+
+const drawBaseLine = (
+ ctx: CanvasRenderingContext2D,
+ points: Point[],
+ { tileThickness, baseFillColor }: ArrowLineOptions
+) => {
+ if (points.length < 2) {
+ return;
+ }
+ ctx.save();
+ ctx.beginPath();
+ points.forEach((point, i) => {
+ if (i === 0) {
+ ctx.moveTo(point.x, point.y);
+ } else {
+ ctx.lineTo(point.x, point.y);
+ }
+ });
+ ctx.lineWidth = tileThickness;
+ ctx.lineJoin = "round";
+ ctx.strokeStyle = baseFillColor;
+ ctx.stroke();
+ ctx.restore();
+};
+
+const drawArrowSegment = (
+ ctx: CanvasRenderingContext2D,
+ image: HTMLImageElement,
+ from: Point,
+ to: Point,
+ { arrowWidth, arrowStep, tileThickness }: ArrowLineOptions
+) => {
+ const dx = to.x - from.x;
+ const dy = to.y - from.y;
+ const length = Math.hypot(dx, dy);
+ if (!length) {
+ return;
+ }
+ const angle = Math.atan2(dy, dx);
+ const tileCount = Math.ceil(length / arrowStep);
+
+ ctx.save();
+ ctx.translate(from.x, from.y);
+ ctx.rotate(angle);
+ ctx.beginPath();
+ ctx.rect(0, -tileThickness / 2, length, tileThickness);
+ ctx.clip();
+ for (let i = 0; i < tileCount; i += 1) {
+ ctx.drawImage(
+ image,
+ i * arrowStep,
+ -tileThickness / 2,
+ arrowWidth,
+ tileThickness
+ );
+ }
+ ctx.restore();
+};
+
+const drawPoint = (
+ ctx: CanvasRenderingContext2D,
+ point: Point,
+ { pointRadius, pointColor }: ArrowLineOptions
+) => {
+ ctx.beginPath();
+ ctx.arc(point.x, point.y, pointRadius, 0, Math.PI * 2);
+ ctx.fillStyle = pointColor;
+ ctx.fill();
+};
+
+export const drawArrowLine = (
+ ctx: CanvasRenderingContext2D,
+ image: HTMLImageElement,
+ points: Point[],
+ options: ArrowLineOptions
+) => {
+ ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
+ drawBaseLine(ctx, points, options);
+ points.slice(1).forEach((to, i) => {
+ drawArrowSegment(ctx, image, points[i], to, options);
+ });
+ points.forEach(point => drawPoint(ctx, point, options));
+};
diff --git a/packages/web/src/feature/page-path.ts b/packages/web/src/feature/page-path.ts
index 0530ab9..59b7de5 100644
--- a/packages/web/src/feature/page-path.ts
+++ b/packages/web/src/feature/page-path.ts
@@ -5,3 +5,4 @@ const PAGE_ROOT = new PageEntry(BASE_URL);
export const PAGE_TOP = PAGE_ROOT;
export const PAGE_ABOUT = PAGE_ROOT.child("about");
+export const PAGE_ARROW_LINE = PAGE_ROOT.child("arrow-line");
diff --git a/packages/web/src/pages/arrow-line/index.tsx b/packages/web/src/pages/arrow-line/index.tsx
new file mode 100644
index 0000000..f62d546
--- /dev/null
+++ b/packages/web/src/pages/arrow-line/index.tsx
@@ -0,0 +1,21 @@
+import { makeSubPageMetadata } from "~/feature/defaultMetadata";
+import { PAGE_ARROW_LINE } from "~/feature/page-path";
+import MockStaticLayout from "~/component/MockStaticLayout";
+import PageMeta from "~/component/PageMeta";
+import ArrowLineCanvas from "~/component/ArrowLineCanvas";
+import ASSET_ARROW_TILE from "~/asset/arrow-line/arrow-tile.svg";
+
+const metadata = makeSubPageMetadata({
+ page: PAGE_ARROW_LINE,
+ subPageTitle: "Arrow Line"
+});
+
+const PageArrowLine = () => (
+
+
+
+
+
+);
+
+export default PageArrowLine;