Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/web/src/asset/arrow-line/arrow-tile.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
140 changes: 140 additions & 0 deletions packages/web/src/component/ArrowLineCanvas.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLCanvasElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const [points, setPoints] = useState<Point[]>([]);
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 (
<Wrapper>
<Toolbar>
<MockActionButton
action={
points.length
? { type: "button", onClick: () => setPoints(list => list.slice(0, -1)) }
: null
}
>
ひとつ戻す
</MockActionButton>
<MockActionButton
action={
points.length ? { type: "button", onClick: () => setPoints([]) } : null
}
>
クリア
</MockActionButton>
</Toolbar>
<MockRangeFormRow
label="矢印の間隔"
value={spacingLevel}
onChange={setSpacingLevel}
min={SPACING_MIN}
max={SPACING_MAX}
step={SPACING_STEP}
error={null}
/>
<StyledCanvas
ref={canvasRef}
width={CANVAS_WIDTH}
height={CANVAS_HEIGHT}
onClick={handleClick}
/>
</Wrapper>
);
};

export default ArrowLineCanvas;
93 changes: 93 additions & 0 deletions packages/web/src/feature/arrow-line-canvas.ts
Original file line number Diff line number Diff line change
@@ -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));
};
1 change: 1 addition & 0 deletions packages/web/src/feature/page-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
21 changes: 21 additions & 0 deletions packages/web/src/pages/arrow-line/index.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
<PageMeta metadata={metadata}>
<MockStaticLayout title="クリックした点を矢印でつなぐツール">
<ArrowLineCanvas arrowImageSrc={ASSET_ARROW_TILE.src} />
</MockStaticLayout>
</PageMeta>
);

export default PageArrowLine;