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;