diff --git a/package.json b/package.json index 02725be..353b2ff 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,21 @@ { "name": "owlbear-rodeo", - "version": "1.1.0", + "version": "1.2.0", "private": true, "dependencies": { "@stripe/stripe-js": "^1.3.2", "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^7.1.2", - "blob-to-buffer": "^1.2.8", + "dexie": "^2.0.4", "interactjs": "^1.9.7", "js-binarypack": "^0.0.9", + "normalize-wheel": "^1.0.1", "react": "^16.13.0", "react-dom": "^16.13.0", "react-modal": "^3.11.2", "react-router-dom": "^5.1.2", "react-scripts": "3.4.0", - "shape-detector": "^0.2.1", "shortid": "^2.2.15", "simple-peer": "^9.6.2", "simplebar-react": "^2.1.0", diff --git a/src/components/AddMapButton.js b/src/components/AddMapButton.js deleted file mode 100644 index 16baa75..0000000 --- a/src/components/AddMapButton.js +++ /dev/null @@ -1,82 +0,0 @@ -import React, { useRef, useState, useEffect } from "react"; -import { IconButton } from "theme-ui"; - -import AddMapModal from "../modals/AddMapModal"; -import AddMapIcon from "../icons/AddMapIcon"; - -const defaultMapSize = 22; - -function AddMapButton({ onMapChange }) { - const [isAddModalOpen, setIsAddModalOpen] = useState(false); - function openModal() { - setIsAddModalOpen(true); - } - function closeModal() { - setIsAddModalOpen(false); - } - - const [imageLoaded, setImageLoaded] = useState(false); - - const mapDataRef = useRef(null); - const [mapSource, setMapSource] = useState(null); - function handleImageUpload(file, fileGridX, fileGridY) { - const url = URL.createObjectURL(file); - let image = new Image(); - image.onload = function () { - mapDataRef.current = { - file, - gridX: fileGridX || gridX, - gridY: fileGridY || gridY, - width: image.width, - height: image.height, - }; - setImageLoaded(true); - }; - image.src = url; - setMapSource(url); - if (fileGridX) { - setGridX(fileGridX); - } - if (fileGridY) { - setGridY(fileGridY); - } - } - - function handleDone() { - if (mapDataRef.current && mapSource) { - onMapChange(mapDataRef.current, mapSource); - } - closeModal(); - } - - const [gridX, setGridX] = useState(defaultMapSize); - const [gridY, setGridY] = useState(defaultMapSize); - useEffect(() => { - if (mapDataRef.current) { - mapDataRef.current.gridX = gridX; - mapDataRef.current.gridY = gridY; - } - }, [gridX, gridY]); - - return ( - <> - - - - - - ); -} - -export default AddMapButton; diff --git a/src/components/ListToken.js b/src/components/ListToken.js deleted file mode 100644 index f90d3d0..0000000 --- a/src/components/ListToken.js +++ /dev/null @@ -1,21 +0,0 @@ -import React, { useRef } from "react"; -import { Image } from "theme-ui"; - -import usePreventTouch from "../helpers/usePreventTouch"; - -function ListToken({ image, className }) { - const imageRef = useRef(); - // Stop touch to prevent 3d touch gesutre on iOS - usePreventTouch(imageRef); - - return ( - - ); -} - -export default ListToken; diff --git a/src/components/Map.js b/src/components/Map.js deleted file mode 100644 index bb4b961..0000000 --- a/src/components/Map.js +++ /dev/null @@ -1,328 +0,0 @@ -import React, { useRef, useEffect, useState } from "react"; -import { Box, Image } from "theme-ui"; -import interact from "interactjs"; - -import ProxyToken from "./ProxyToken"; -import TokenMenu from "./TokenMenu"; -import MapToken from "./MapToken"; -import MapDrawing from "./MapDrawing"; -import MapControls from "./MapControls"; - -import { omit } from "../helpers/shared"; - -const mapTokenProxyClassName = "map-token__proxy"; -const mapTokenMenuClassName = "map-token__menu"; -const zoomSpeed = -0.005; -const minZoom = 0.1; -const maxZoom = 5; - -function Map({ - mapSource, - mapData, - tokens, - onMapTokenChange, - onMapTokenRemove, - onMapChange, - onMapDraw, - onMapDrawUndo, - onMapDrawRedo, - drawActions, - drawActionIndex, -}) { - function handleProxyDragEnd(isOnMap, token) { - if (isOnMap && onMapTokenChange) { - onMapTokenChange(token); - } - - if (!isOnMap && onMapTokenRemove) { - onMapTokenRemove(token); - } - } - - /** - * Map drawing - */ - - const [selectedTool, setSelectedTool] = useState("pan"); - const [brushColor, setBrushColor] = useState("black"); - const [useBrushGridSnapping, setUseBrushGridSnapping] = useState(false); - const [useBrushBlending, setUseBrushBlending] = useState(false); - const [useBrushGesture, setUseBrushGesture] = useState(false); - - const [drawnShapes, setDrawnShapes] = useState([]); - function handleShapeAdd(shape) { - onMapDraw({ type: "add", shapes: [shape] }); - } - - function handleShapeRemove(shapeId) { - onMapDraw({ type: "remove", shapeIds: [shapeId] }); - } - - function handleShapeRemoveAll() { - onMapDraw({ type: "remove", shapeIds: drawnShapes.map((s) => s.id) }); - } - - // Replay the draw actions and convert them to shapes for the map drawing - useEffect(() => { - let shapesById = {}; - for (let i = 0; i <= drawActionIndex; i++) { - const action = drawActions[i]; - if (action.type === "add") { - for (let shape of action.shapes) { - shapesById[shape.id] = shape; - } - } - if (action.type === "remove") { - shapesById = omit(shapesById, action.shapeIds); - } - } - setDrawnShapes(Object.values(shapesById)); - }, [drawActions, drawActionIndex]); - - const disabledTools = []; - if (!mapData) { - disabledTools.push("pan"); - disabledTools.push("brush"); - } - if (drawnShapes.length === 0) { - disabledTools.push("erase"); - } - - /** - * Map movement - */ - - const mapTranslateRef = useRef({ x: 0, y: 0 }); - const mapScaleRef = useRef(1); - const mapMoveContainerRef = useRef(); - function setTranslateAndScale(newTranslate, newScale) { - const moveContainer = mapMoveContainerRef.current; - moveContainer.style.transform = `translate(${newTranslate.x}px, ${newTranslate.y}px) scale(${newScale})`; - mapScaleRef.current = newScale; - mapTranslateRef.current = newTranslate; - } - - useEffect(() => { - function handleMove(event, isGesture) { - const scale = mapScaleRef.current; - const translate = mapTranslateRef.current; - - let newScale = scale; - let newTranslate = translate; - - if (isGesture) { - newScale = Math.max(Math.min(scale + event.ds, maxZoom), minZoom); - } - - if (selectedTool === "pan" || isGesture) { - newTranslate = { - x: translate.x + event.dx, - y: translate.y + event.dy, - }; - } - setTranslateAndScale(newTranslate, newScale); - } - const mapInteract = interact(".map") - .gesturable({ - listeners: { - move: (e) => handleMove(e, true), - }, - }) - .draggable({ - inertia: true, - listeners: { - move: (e) => handleMove(e, false), - }, - cursorChecker: () => { - return selectedTool === "pan" && mapData ? "move" : "default"; - }, - }) - .on("doubletap", (event) => { - event.preventDefault(); - if (selectedTool === "pan") { - setTranslateAndScale({ x: 0, y: 0 }, 1); - } - }); - - return () => { - mapInteract.unset(); - }; - }, [selectedTool, mapData]); - - // Reset map transform when map changes - useEffect(() => { - setTranslateAndScale({ x: 0, y: 0 }, 1); - }, [mapSource]); - - // Bind the wheel event of the map via a ref - // in order to support non-passive event listening - // to allow the track pad zoom to be interrupted - // see https://github.com/facebook/react/issues/14856 - useEffect(() => { - const mapContainer = mapContainerRef.current; - - function handleZoom(event) { - // Stop overscroll on chrome and safari - // also stop pinch to zoom on chrome - event.preventDefault(); - - const scale = mapScaleRef.current; - const translate = mapTranslateRef.current; - - const deltaY = event.deltaY * zoomSpeed; - const newScale = Math.max(Math.min(scale + deltaY, maxZoom), minZoom); - - setTranslateAndScale(translate, newScale); - } - - if (mapContainer) { - mapContainer.addEventListener("wheel", handleZoom, { - passive: false, - }); - } - - return () => { - if (mapContainer) { - mapContainer.removeEventListener("wheel", handleZoom); - } - }; - }, []); - - /** - * Member setup - */ - - const mapRef = useRef(null); - const mapContainerRef = useRef(); - const gridX = mapData && mapData.gridX; - const gridY = mapData && mapData.gridY; - const gridSizeNormalized = { x: 1 / gridX || 0, y: 1 / gridY || 0 }; - const tokenSizePercent = gridSizeNormalized.x * 100; - const aspectRatio = (mapData && mapData.width / mapData.height) || 1; - - const mapImage = ( - - - - ); - - const mapTokens = ( - - {Object.values(tokens).map((token) => ( - - ))} - - ); - - return ( - <> - - - - - {mapImage} - - {mapTokens} - - - - - - - - ); -} - -export default Map; diff --git a/src/components/MapControls.js b/src/components/MapControls.js deleted file mode 100644 index 2e9b33f..0000000 --- a/src/components/MapControls.js +++ /dev/null @@ -1,298 +0,0 @@ -import React, { useState, useEffect, useRef } from "react"; -import { Flex, Box, IconButton, Label } from "theme-ui"; - -import AddMapButton from "./AddMapButton"; -import ExpandMoreIcon from "../icons/ExpandMoreIcon"; -import PanToolIcon from "../icons/PanToolIcon"; -import BrushToolIcon from "../icons/BrushToolIcon"; -import EraseToolIcon from "../icons/EraseToolIcon"; -import UndoIcon from "../icons/UndoIcon"; -import RedoIcon from "../icons/RedoIcon"; -import GridOnIcon from "../icons/GridOnIcon"; -import GridOffIcon from "../icons/GridOffIcon"; -import BlendOnIcon from "../icons/BlendOnIcon"; -import BlendOffIcon from "../icons/BlendOffIcon"; -import GestureOnIcon from "../icons/GestureOnIcon"; -import GestureOffIcon from "../icons/GestureOffIcon"; - -import colors, { colorOptions } from "../helpers/colors"; - -import MapMenu from "./MapMenu"; -import EraseAllIcon from "../icons/EraseAllIcon"; - -function MapControls({ - onMapChange, - onToolChange, - selectedTool, - disabledTools, - onUndo, - onRedo, - undoDisabled, - redoDisabled, - brushColor, - onBrushColorChange, - onEraseAll, - useBrushGridSnapping, - onBrushGridSnappingChange, - useBrushBlending, - onBrushBlendingChange, - useBrushGesture, - onBrushGestureChange, -}) { - const [isExpanded, setIsExpanded] = useState(false); - - const subMenus = { - brush: ( - - - {colorOptions.map((color) => ( - onBrushColorChange(color)} - aria-label={`Brush Color ${color}`} - > - {brushColor === color && ( - - )} - - ))} - - - onBrushGridSnappingChange(!useBrushGridSnapping)} - > - {useBrushGridSnapping ? : } - - onBrushBlendingChange(!useBrushBlending)} - > - {useBrushBlending ? : } - - onBrushGestureChange(!useBrushGesture)} - > - {useBrushGesture ? : } - - - - ), - erase: ( - - - - ), - }; - - const [currentSubmenu, setCurrentSubmenu] = useState(null); - const [currentSubmenuOptions, setCurrentSubmenuOptions] = useState({}); - - function handleToolClick(event, tool) { - if (tool !== selectedTool) { - onToolChange(tool); - } else if (currentSubmenu) { - setCurrentSubmenu(null); - setCurrentSubmenuOptions({}); - } else if (subMenus[tool]) { - const toolRect = event.target.getBoundingClientRect(); - setCurrentSubmenu(tool); - setCurrentSubmenuOptions({ - // Align the right of the submenu to the left of the tool and center vertically - left: `${toolRect.left - 16}px`, - top: `${toolRect.bottom - toolRect.height / 2}px`, - style: { transform: "translate(-100%, -50%)" }, - // Exclude this node from the sub menus auto close - excludeNode: event.target, - }); - } - } - - // Detect when a tool becomes disabled and switch to to the pan tool - useEffect(() => { - if (disabledTools.includes(selectedTool)) { - onToolChange("pan"); - } - }, [selectedTool, disabledTools, onToolChange]); - - const divider = ( - - ); - - const expanedMenuRef = useRef(); - - return ( - <> - - setIsExpanded(!isExpanded)} - sx={{ - transform: `rotate(${isExpanded ? "0" : "180deg"})`, - display: "block", - backgroundColor: "overlay", - borderRadius: "50%", - }} - m={2} - > - - - - - {divider} - handleToolClick(e, "pan")} - sx={{ color: selectedTool === "pan" ? "primary" : "text" }} - disabled={disabledTools.includes("pan")} - > - - - handleToolClick(e, "brush")} - sx={{ color: selectedTool === "brush" ? "primary" : "text" }} - disabled={disabledTools.includes("brush")} - > - - - handleToolClick(e, "erase")} - sx={{ color: selectedTool === "erase" ? "primary" : "text" }} - disabled={disabledTools.includes("erase")} - > - - - {divider} - onUndo()} - disabled={undoDisabled} - > - - - onRedo()} - disabled={redoDisabled} - > - - - - - { - setCurrentSubmenu(null); - setCurrentSubmenuOptions({}); - }} - {...currentSubmenuOptions} - > - {currentSubmenu && subMenus[currentSubmenu]} - - - ); -} - -export default MapControls; diff --git a/src/components/MapDrawing.js b/src/components/MapDrawing.js deleted file mode 100644 index 2387eef..0000000 --- a/src/components/MapDrawing.js +++ /dev/null @@ -1,233 +0,0 @@ -import React, { useRef, useEffect, useState } from "react"; -import simplify from "simplify-js"; -import shortid from "shortid"; - -import colors from "../helpers/colors"; -import { snapPositionToGrid } from "../helpers/shared"; - -import { pointsToGesture, gestureToData } from "../helpers/gestures"; - -function MapDrawing({ - width, - height, - selectedTool, - shapes, - onShapeAdd, - onShapeRemove, - brushColor, - useGridSnapping, - gridSize, - useBrushBlending, - useBrushGesture, -}) { - const canvasRef = useRef(); - const containerRef = useRef(); - - const [brushPoints, setBrushPoints] = useState([]); - const [isDrawing, setIsDrawing] = useState(false); - const [pointerPosition, setPointerPosition] = useState({ x: -1, y: -1 }); - - // Reset pointer position when tool changes - useEffect(() => { - setPointerPosition({ x: -1, y: -1 }); - }, [selectedTool]); - - function getRelativePointerPosition(event) { - const container = containerRef.current; - if (container) { - const containerRect = container.getBoundingClientRect(); - const x = (event.clientX - containerRect.x) / containerRect.width; - const y = (event.clientY - containerRect.y) / containerRect.height; - return { x, y }; - } - } - - function handleStart(event) { - if (event.touches && event.touches.length !== 1) { - setIsDrawing(false); - setBrushPoints([]); - return; - } - const pointer = event.touches ? event.touches[0] : event; - const position = getRelativePointerPosition(pointer); - setPointerPosition(position); - setIsDrawing(true); - if (selectedTool === "brush") { - const brushPosition = useGridSnapping - ? snapPositionToGrid(position, gridSize) - : position; - setBrushPoints([brushPosition]); - } - } - - function handleMove(event) { - if (event.touches && event.touches.length !== 1) { - return; - } - const pointer = event.touches ? event.touches[0] : event; - const position = getRelativePointerPosition(pointer); - if (selectedTool === "erase") { - setPointerPosition(position); - } - if (isDrawing && selectedTool === "brush") { - setPointerPosition(position); - const brushPosition = useGridSnapping - ? snapPositionToGrid(position, gridSize) - : position; - setBrushPoints((prevPoints) => { - if (prevPoints[prevPoints.length - 1] === brushPosition) { - return prevPoints; - } - return [...prevPoints, brushPosition]; - }); - } - } - - function handleStop(event) { - if (event.touches && event.touches.length !== 0) { - return; - } - setIsDrawing(false); - if (selectedTool === "brush") { - if (brushPoints.length > 1) { - const simplifiedPoints = simplify(brushPoints, 0.001); - const type = useBrushGesture - ? pointsToGesture(simplifiedPoints) - : "path"; - - if (type !== null) { - const data = - type === "path" - ? { points: simplifiedPoints } - : gestureToData(simplifiedPoints, type); - - onShapeAdd({ - type, - data, - id: shortid.generate(), - color: brushColor, - blend: useBrushBlending, - }); - } - - setBrushPoints([]); - } - } - if (selectedTool === "erase" && hoveredShapeRef.current) { - onShapeRemove(hoveredShapeRef.current.id); - } - } - - const hoveredShapeRef = useRef(null); - useEffect(() => { - function pointsToPath(points) { - const path = new Path2D(); - path.moveTo(points[0].x * width, points[0].y * height); - for (let point of points.slice(1)) { - path.lineTo(point.x * width, point.y * height); - } - path.closePath(); - return path; - } - - function circleToPath(x, y, radius) { - const path = new Path2D(); - const minSide = width < height ? width : height; - path.arc(x * width, y * height, radius * minSide, 0, 2 * Math.PI, true); - return path; - } - - function rectangleToPath(x, y, w, h) { - const path = new Path2D(); - path.rect(x * width, y * height, w * width, h * height); - return path; - } - - function shapeToPath(shape) { - const data = shape.data; - if (shape.type === "path") { - return pointsToPath(data.points); - } else if (shape.type === "circle") { - return circleToPath(data.x, data.y, data.radius); - } else if (shape.type === "rectangle") { - return rectangleToPath(data.x, data.y, data.width, data.height); - } else if (shape.type === "triangle") { - return pointsToPath(data.points); - } - } - - function drawPath(path, color, blend, context) { - context.globalAlpha = blend ? 0.5 : 1.0; - context.fillStyle = color; - context.strokeStyle = color; - context.stroke(path); - context.fill(path); - } - - const canvas = canvasRef.current; - if (canvas) { - const context = canvas.getContext("2d"); - - context.clearRect(0, 0, width, height); - let hoveredShape = null; - for (let shape of shapes) { - const path = shapeToPath(shape); - // Detect hover - if (selectedTool === "erase") { - if ( - context.isPointInPath( - path, - pointerPosition.x * width, - pointerPosition.y * height - ) - ) { - hoveredShape = shape; - } - } - drawPath(path, colors[shape.color], shape.blend, context); - } - if (selectedTool === "brush" && brushPoints.length > 0) { - const path = pointsToPath(brushPoints); - drawPath(path, colors[brushColor], useBrushBlending, context); - } - if (hoveredShape) { - const path = shapeToPath(hoveredShape); - drawPath(path, "#BB99FF", true, context); - } - hoveredShapeRef.current = hoveredShape; - } - }, [ - shapes, - width, - height, - pointerPosition, - isDrawing, - selectedTool, - brushPoints, - brushColor, - useBrushGesture, - useBrushBlending, - ]); - - return ( -
- -
- ); -} - -export default MapDrawing; diff --git a/src/components/SettingsButton.js b/src/components/SettingsButton.js new file mode 100644 index 0000000..750978c --- /dev/null +++ b/src/components/SettingsButton.js @@ -0,0 +1,31 @@ +import React, { useState } from "react"; +import { IconButton } from "theme-ui"; + +import SettingsIcon from "../icons/SettingsIcon"; +import SettingsModal from "../modals/SettingsModal"; + +function SettingsButton() { + const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); + function openModal() { + setIsSettingsModalOpen(true); + } + function closeModal() { + setIsSettingsModalOpen(false); + } + + return ( + <> + + + + + + ); +} + +export default SettingsButton; diff --git a/src/components/map/Map.js b/src/components/map/Map.js new file mode 100644 index 0000000..965afef --- /dev/null +++ b/src/components/map/Map.js @@ -0,0 +1,323 @@ +import React, { useRef, useEffect, useState } from "react"; +import { Box, Image } from "theme-ui"; + +import ProxyToken from "../token/ProxyToken"; +import TokenMenu from "../token/TokenMenu"; +import MapToken from "./MapToken"; +import MapDrawing from "./MapDrawing"; +import MapFog from "./MapFog"; +import MapControls from "./MapControls"; + +import { omit } from "../../helpers/shared"; +import useDataSource from "../../helpers/useDataSource"; +import MapInteraction from "./MapInteraction"; + +import { mapSources as defaultMapSources } from "../../maps"; + +const mapTokenProxyClassName = "map-token__proxy"; +const mapTokenMenuClassName = "map-token__menu"; + +function Map({ + map, + mapState, + tokens, + onMapTokenStateChange, + onMapTokenStateRemove, + onMapChange, + onMapStateChange, + onMapDraw, + onMapDrawUndo, + onMapDrawRedo, + onFogDraw, + onFogDrawUndo, + onFogDrawRedo, + allowMapDrawing, + allowFogDrawing, + disabledTokens, + loading, +}) { + const mapSource = useDataSource(map, defaultMapSources); + + function handleProxyDragEnd(isOnMap, tokenState) { + if (isOnMap && onMapTokenStateChange) { + onMapTokenStateChange(tokenState); + } + + if (!isOnMap && onMapTokenStateRemove) { + onMapTokenStateRemove(tokenState); + } + } + + /** + * Map drawing + */ + + const [selectedToolId, setSelectedToolId] = useState("pan"); + const [toolSettings, setToolSettings] = useState({ + fog: { type: "add", useEdgeSnapping: true, useGridSnapping: false }, + brush: { + color: "darkGray", + type: "stroke", + useBlending: false, + }, + shape: { + color: "red", + type: "rectangle", + useBlending: true, + }, + }); + + function handleToolSettingChange(tool, change) { + setToolSettings((prevSettings) => ({ + ...prevSettings, + [tool]: { + ...prevSettings[tool], + ...change, + }, + })); + } + + function handleToolAction(action) { + if (action === "eraseAll") { + onMapDraw({ + type: "remove", + shapeIds: mapShapes.map((s) => s.id), + timestamp: Date.now(), + }); + } + if (action === "mapUndo") { + onMapDrawUndo(); + } + if (action === "mapRedo") { + onMapDrawRedo(); + } + if (action === "fogUndo") { + onFogDrawUndo(); + } + if (action === "fogRedo") { + onFogDrawRedo(); + } + } + + const [mapShapes, setMapShapes] = useState([]); + function handleMapShapeAdd(shape) { + onMapDraw({ type: "add", shapes: [shape] }); + } + + function handleMapShapeRemove(shapeId) { + onMapDraw({ type: "remove", shapeIds: [shapeId] }); + } + + const [fogShapes, setFogShapes] = useState([]); + function handleFogShapeAdd(shape) { + onFogDraw({ type: "add", shapes: [shape] }); + } + + function handleFogShapeRemove(shapeId) { + onFogDraw({ type: "remove", shapeIds: [shapeId] }); + } + + function handleFogShapeEdit(shape) { + onFogDraw({ type: "edit", shapes: [shape] }); + } + + // Replay the draw actions and convert them to shapes for the map drawing + useEffect(() => { + if (!mapState) { + return; + } + function actionsToShapes(actions, actionIndex) { + let shapesById = {}; + for (let i = 0; i <= actionIndex; i++) { + const action = actions[i]; + if (action.type === "add" || action.type === "edit") { + for (let shape of action.shapes) { + shapesById[shape.id] = shape; + } + } + if (action.type === "remove") { + shapesById = omit(shapesById, action.shapeIds); + } + } + return Object.values(shapesById); + } + + setMapShapes( + actionsToShapes(mapState.mapDrawActions, mapState.mapDrawActionIndex) + ); + setFogShapes( + actionsToShapes(mapState.fogDrawActions, mapState.fogDrawActionIndex) + ); + }, [mapState]); + + const disabledControls = []; + if (!allowMapDrawing) { + disabledControls.push("brush"); + disabledControls.push("shape"); + disabledControls.push("erase"); + } + if (!map) { + disabledControls.push("pan"); + } + if (mapShapes.length === 0) { + disabledControls.push("erase"); + } + if (!allowFogDrawing) { + disabledControls.push("fog"); + } + + const disabledSettings = { fog: [], brush: [], shape: [], erase: [] }; + if (!mapState || mapState.mapDrawActionIndex < 0) { + disabledSettings.brush.push("undo"); + disabledSettings.shape.push("undo"); + disabledSettings.erase.push("undo"); + } + if ( + !mapState || + mapState.mapDrawActionIndex === mapState.mapDrawActions.length - 1 + ) { + disabledSettings.brush.push("redo"); + disabledSettings.shape.push("redo"); + disabledSettings.erase.push("redo"); + } + if (fogShapes.length === 0) { + disabledSettings.fog.push("undo"); + } + if ( + !mapState || + mapState.fogDrawActionIndex === mapState.fogDrawActions.length - 1 + ) { + disabledSettings.fog.push("redo"); + } + + /** + * Member setup + */ + + const mapRef = useRef(null); + + const gridX = map && map.gridX; + const gridY = map && map.gridY; + const gridSizeNormalized = { x: 1 / gridX || 0, y: 1 / gridY || 0 }; + const tokenSizePercent = gridSizeNormalized.x * 100; + const aspectRatio = (map && map.width / map.height) || 1; + + const mapImage = ( + + + + ); + + const mapTokens = ( + + {mapState && + Object.values(mapState.tokens).map((tokenState) => ( + token.id === tokenState.tokenId)} + tokenState={tokenState} + tokenSizePercent={tokenSizePercent} + className={`${mapTokenProxyClassName} ${mapTokenMenuClassName}`} + /> + ))} + + ); + + const mapDrawing = ( + + ); + + const mapFog = ( + + ); + + const mapControls = ( + + ); + return ( + <> + + {map && mapImage} + {map && mapDrawing} + {map && mapFog} + {map && mapTokens} + + + + + ); +} + +export default Map; diff --git a/src/components/map/MapControls.js b/src/components/map/MapControls.js new file mode 100644 index 0000000..7399798 --- /dev/null +++ b/src/components/map/MapControls.js @@ -0,0 +1,230 @@ +import React, { useState, Fragment, useEffect, useRef } from "react"; +import { IconButton, Flex, Box } from "theme-ui"; + +import RadioIconButton from "./controls/RadioIconButton"; +import Divider from "./controls/Divider"; + +import SelectMapButton from "./SelectMapButton"; + +import FogToolSettings from "./controls/FogToolSettings"; +import BrushToolSettings from "./controls/BrushToolSettings"; +import ShapeToolSettings from "./controls/ShapeToolSettings"; +import EraseToolSettings from "./controls/EraseToolSettings"; + +import PanToolIcon from "../../icons/PanToolIcon"; +import FogToolIcon from "../../icons/FogToolIcon"; +import BrushToolIcon from "../../icons/BrushToolIcon"; +import ShapeToolIcon from "../../icons/ShapeToolIcon"; +import EraseToolIcon from "../../icons/EraseToolIcon"; +import ExpandMoreIcon from "../../icons/ExpandMoreIcon"; + +function MapContols({ + onMapChange, + onMapStateChange, + currentMap, + selectedToolId, + onSelectedToolChange, + toolSettings, + onToolSettingChange, + onToolAction, + disabledControls, + disabledSettings, +}) { + const [isExpanded, setIsExpanded] = useState(false); + + const toolsById = { + pan: { + id: "pan", + icon: , + title: "Pan Tool", + }, + fog: { + id: "fog", + icon: , + title: "Fog Tool", + SettingsComponent: FogToolSettings, + }, + brush: { + id: "brush", + icon: , + title: "Brush Tool", + SettingsComponent: BrushToolSettings, + }, + shape: { + id: "shape", + icon: , + title: "Shape Tool", + SettingsComponent: ShapeToolSettings, + }, + erase: { + id: "erase", + icon: , + title: "Erase tool", + SettingsComponent: EraseToolSettings, + }, + }; + const tools = ["pan", "fog", "brush", "shape", "erase"]; + + const sections = [ + { + id: "map", + component: ( + + ), + }, + { + id: "drawing", + component: tools.map((tool) => ( + onSelectedToolChange(tool)} + isSelected={selectedToolId === tool} + disabled={disabledControls.includes(tool)} + > + {toolsById[tool].icon} + + )), + }, + ]; + + let controls = null; + if (sections.length === 1 && sections[0].id === "map") { + controls = ( + + {sections[0].component} + + ); + } else if (sections.length > 0) { + controls = ( + <> + setIsExpanded(!isExpanded)} + sx={{ + transform: `rotate(${isExpanded ? "0" : "180deg"})`, + display: "block", + backgroundColor: "overlay", + borderRadius: "50%", + }} + m={2} + > + + + + {sections.map((section, index) => ( + + {section.component} + {index !== sections.length - 1 && } + + ))} + + + ); + } + + const controlsRef = useRef(); + const settingsRef = useRef(); + + function getToolSettings() { + const Settings = toolsById[selectedToolId].SettingsComponent; + if (Settings) { + return ( + + + onToolSettingChange(selectedToolId, change) + } + onToolAction={onToolAction} + disabledActions={disabledSettings[selectedToolId]} + /> + + ); + } else { + return null; + } + } + + // Stop map drawing from happening when selecting controls + // Not using react events as they seem to trigger after dom events + useEffect(() => { + function stopPropagation(e) { + e.stopPropagation(); + } + const controls = controlsRef.current; + if (controls) { + controls.addEventListener("mousedown", stopPropagation); + controls.addEventListener("touchstart", stopPropagation); + } + const settings = settingsRef.current; + if (settings) { + settings.addEventListener("mousedown", stopPropagation); + settings.addEventListener("touchstart", stopPropagation); + } + + return () => { + if (controls) { + controls.removeEventListener("mousedown", stopPropagation); + controls.removeEventListener("touchstart", stopPropagation); + } + if (settings) { + settings.removeEventListener("mousedown", stopPropagation); + settings.removeEventListener("touchstart", stopPropagation); + } + }; + }); + + return ( + <> + + {controls} + + {getToolSettings()} + + ); +} + +export default MapContols; diff --git a/src/components/map/MapDrawing.js b/src/components/map/MapDrawing.js new file mode 100644 index 0000000..fbc5416 --- /dev/null +++ b/src/components/map/MapDrawing.js @@ -0,0 +1,261 @@ +import React, { useRef, useEffect, useState, useContext } from "react"; +import shortid from "shortid"; + +import { compare as comparePoints } from "../../helpers/vector2"; +import { + getBrushPositionForTool, + getDefaultShapeData, + getUpdatedShapeData, + isShapeHovered, + drawShape, + simplifyPoints, + getRelativePointerPosition, +} from "../../helpers/drawing"; + +import MapInteractionContext from "../../contexts/MapInteractionContext"; + +function MapDrawing({ + width, + height, + selectedTool, + toolSettings, + shapes, + onShapeAdd, + onShapeRemove, + gridSize, +}) { + const canvasRef = useRef(); + const containerRef = useRef(); + + const [isPointerDown, setIsPointerDown] = useState(false); + const [drawingShape, setDrawingShape] = useState(null); + const [pointerPosition, setPointerPosition] = useState({ x: -1, y: -1 }); + + const shouldHover = selectedTool === "erase"; + const isEditing = + selectedTool === "brush" || + selectedTool === "shape" || + selectedTool === "erase"; + + const { scaleRef } = useContext(MapInteractionContext); + + // Reset pointer position when tool changes + useEffect(() => { + setPointerPosition({ x: -1, y: -1 }); + }, [selectedTool]); + + function handleStart(event) { + if (!isEditing) { + return; + } + if (event.touches && event.touches.length !== 1) { + setIsPointerDown(false); + setDrawingShape(null); + return; + } + const pointer = event.touches ? event.touches[0] : event; + const position = getRelativePointerPosition(pointer, containerRef.current); + setPointerPosition(position); + setIsPointerDown(true); + const brushPosition = getBrushPositionForTool( + position, + selectedTool, + toolSettings, + gridSize, + shapes + ); + const commonShapeData = { + color: toolSettings && toolSettings.color, + blend: toolSettings && toolSettings.useBlending, + id: shortid.generate(), + }; + if (selectedTool === "brush") { + setDrawingShape({ + type: "path", + pathType: toolSettings.type, + data: { points: [brushPosition] }, + strokeWidth: toolSettings.type === "stroke" ? 1 : 0, + ...commonShapeData, + }); + } else if (selectedTool === "shape") { + setDrawingShape({ + type: "shape", + shapeType: toolSettings.type, + data: getDefaultShapeData(toolSettings.type, brushPosition), + strokeWidth: 0, + ...commonShapeData, + }); + } + } + + function handleMove(event) { + if (!isEditing) { + return; + } + if (event.touches && event.touches.length !== 1) { + return; + } + const pointer = event.touches ? event.touches[0] : event; + // Set pointer position every frame for erase tool and fog + if (shouldHover) { + const position = getRelativePointerPosition( + pointer, + containerRef.current + ); + setPointerPosition(position); + } + if (isPointerDown) { + const position = getRelativePointerPosition( + pointer, + containerRef.current + ); + setPointerPosition(position); + const brushPosition = getBrushPositionForTool( + position, + selectedTool, + toolSettings, + gridSize, + shapes + ); + if (selectedTool === "brush") { + setDrawingShape((prevShape) => { + const prevPoints = prevShape.data.points; + if ( + comparePoints( + prevPoints[prevPoints.length - 1], + brushPosition, + 0.001 + ) + ) { + return prevShape; + } + const simplified = simplifyPoints( + [...prevPoints, brushPosition], + gridSize, + scaleRef.current + ); + return { + ...prevShape, + data: { points: simplified }, + }; + }); + } else if (selectedTool === "shape") { + setDrawingShape((prevShape) => ({ + ...prevShape, + data: getUpdatedShapeData( + prevShape.shapeType, + prevShape.data, + brushPosition, + gridSize + ), + })); + } + } + } + + function handleStop(event) { + if (!isEditing) { + return; + } + if (event.touches && event.touches.length !== 0) { + return; + } + if (selectedTool === "brush" && drawingShape) { + if (drawingShape.data.points.length > 1) { + onShapeAdd(drawingShape); + } + } else if (selectedTool === "shape" && drawingShape) { + onShapeAdd(drawingShape); + } + + if (selectedTool === "erase" && hoveredShapeRef.current && isPointerDown) { + onShapeRemove(hoveredShapeRef.current.id); + } + setIsPointerDown(false); + setDrawingShape(null); + } + + // Add listeners for draw events on map to allow drawing past the bounds + // of the container + useEffect(() => { + const map = document.querySelector(".map"); + map.addEventListener("mousedown", handleStart); + map.addEventListener("mousemove", handleMove); + map.addEventListener("mouseup", handleStop); + map.addEventListener("touchstart", handleStart); + map.addEventListener("touchmove", handleMove); + map.addEventListener("touchend", handleStop); + + return () => { + map.removeEventListener("mousedown", handleStart); + map.removeEventListener("mousemove", handleMove); + map.removeEventListener("mouseup", handleStop); + map.removeEventListener("touchstart", handleStart); + map.removeEventListener("touchmove", handleMove); + map.removeEventListener("touchend", handleStop); + }; + }); + + /** + * Rendering + */ + const hoveredShapeRef = useRef(null); + useEffect(() => { + const canvas = canvasRef.current; + if (canvas) { + const context = canvas.getContext("2d"); + + context.clearRect(0, 0, width, height); + let hoveredShape = null; + for (let shape of shapes) { + if (shouldHover) { + if (isShapeHovered(shape, context, pointerPosition, width, height)) { + hoveredShape = shape; + } + } + drawShape(shape, context, gridSize, width, height); + } + if (drawingShape) { + drawShape(drawingShape, context, gridSize, width, height); + } + if (hoveredShape) { + const shape = { ...hoveredShape, color: "#BB99FF", blend: true }; + drawShape(shape, context, gridSize, width, height); + } + hoveredShapeRef.current = hoveredShape; + } + }, [ + shapes, + width, + height, + pointerPosition, + isPointerDown, + selectedTool, + drawingShape, + gridSize, + shouldHover, + ]); + + return ( +
+ +
+ ); +} + +export default MapDrawing; diff --git a/src/components/map/MapFog.js b/src/components/map/MapFog.js new file mode 100644 index 0000000..c0cbfba --- /dev/null +++ b/src/components/map/MapFog.js @@ -0,0 +1,276 @@ +import React, { useRef, useEffect, useState, useContext } from "react"; +import shortid from "shortid"; + +import { compare as comparePoints } from "../../helpers/vector2"; +import { + getBrushPositionForTool, + isShapeHovered, + drawShape, + simplifyPoints, + getRelativePointerPosition, +} from "../../helpers/drawing"; + +import MapInteractionContext from "../../contexts/MapInteractionContext"; + +import diagonalPattern from "../../images/DiagonalPattern.png"; + +function MapFog({ + width, + height, + isEditing, + toolSettings, + shapes, + onShapeAdd, + onShapeRemove, + onShapeEdit, + gridSize, +}) { + const canvasRef = useRef(); + const containerRef = useRef(); + + const [isPointerDown, setIsPointerDown] = useState(false); + const [drawingShape, setDrawingShape] = useState(null); + const [pointerPosition, setPointerPosition] = useState({ x: -1, y: -1 }); + + const shouldHover = + isEditing && + (toolSettings.type === "toggle" || toolSettings.type === "remove"); + + const { scaleRef } = useContext(MapInteractionContext); + + // Reset pointer position when tool changes + useEffect(() => { + setPointerPosition({ x: -1, y: -1 }); + }, [isEditing, toolSettings]); + + function handleStart(event) { + if (!isEditing) { + return; + } + if (event.touches && event.touches.length !== 1) { + setIsPointerDown(false); + setDrawingShape(null); + return; + } + const pointer = event.touches ? event.touches[0] : event; + const position = getRelativePointerPosition(pointer, containerRef.current); + setPointerPosition(position); + setIsPointerDown(true); + const brushPosition = getBrushPositionForTool( + position, + "fog", + toolSettings, + gridSize, + shapes + ); + if (isEditing && toolSettings.type === "add") { + setDrawingShape({ + type: "fog", + data: { points: [brushPosition] }, + strokeWidth: 0.5, + color: "black", + blend: true, // Blend while drawing + id: shortid.generate(), + visible: true, + }); + } + } + + function handleMove(event) { + if (!isEditing) { + return; + } + if (event.touches && event.touches.length !== 1) { + return; + } + const pointer = event.touches ? event.touches[0] : event; + const position = getRelativePointerPosition(pointer, containerRef.current); + // Set pointer position every frame for erase tool and fog + if (shouldHover) { + setPointerPosition(position); + } + if (isPointerDown) { + setPointerPosition(position); + const brushPosition = getBrushPositionForTool( + position, + "fog", + toolSettings, + gridSize, + shapes + ); + if (isEditing && toolSettings.type === "add" && drawingShape) { + setDrawingShape((prevShape) => { + const prevPoints = prevShape.data.points; + if ( + comparePoints( + prevPoints[prevPoints.length - 1], + brushPosition, + 0.001 + ) + ) { + return prevShape; + } + return { + ...prevShape, + data: { points: [...prevPoints, brushPosition] }, + }; + }); + } + } + } + + function handleStop(event) { + if (!isEditing) { + return; + } + if (event.touches && event.touches.length !== 0) { + return; + } + if (isEditing && toolSettings.type === "add" && drawingShape) { + if (drawingShape.data.points.length > 1) { + const shape = { + ...drawingShape, + data: { + points: simplifyPoints( + drawingShape.data.points, + gridSize, + // Downscale fog as smoothing doesn't currently work with edge snapping + scaleRef.current / 2 + ), + }, + blend: false, + }; + onShapeAdd(shape); + } + } + + if (hoveredShapeRef.current && isPointerDown) { + if (toolSettings.type === "remove") { + onShapeRemove(hoveredShapeRef.current.id); + } else if (toolSettings.type === "toggle") { + onShapeEdit({ + ...hoveredShapeRef.current, + visible: !hoveredShapeRef.current.visible, + }); + } + } + setDrawingShape(null); + setIsPointerDown(false); + } + + // Add listeners for draw events on map to allow drawing past the bounds + // of the container + useEffect(() => { + const map = document.querySelector(".map"); + map.addEventListener("mousedown", handleStart); + map.addEventListener("mousemove", handleMove); + map.addEventListener("mouseup", handleStop); + map.addEventListener("touchstart", handleStart); + map.addEventListener("touchmove", handleMove); + map.addEventListener("touchend", handleStop); + + return () => { + map.removeEventListener("mousedown", handleStart); + map.removeEventListener("mousemove", handleMove); + map.removeEventListener("mouseup", handleStop); + map.removeEventListener("touchstart", handleStart); + map.removeEventListener("touchmove", handleMove); + map.removeEventListener("touchend", handleStop); + }; + }); + + /** + * Rendering + */ + const hoveredShapeRef = useRef(null); + const diagonalPatternRef = useRef(); + + useEffect(() => { + let image = new Image(); + image.src = diagonalPattern; + diagonalPatternRef.current = image; + }, []); + + useEffect(() => { + const canvas = canvasRef.current; + if (canvas) { + const context = canvas.getContext("2d"); + + context.clearRect(0, 0, width, height); + let hoveredShape = null; + if (isEditing) { + const editPattern = context.createPattern( + diagonalPatternRef.current, + "repeat" + ); + for (let shape of shapes) { + if (shouldHover) { + if ( + isShapeHovered(shape, context, pointerPosition, width, height) + ) { + hoveredShape = shape; + } + } + drawShape( + { + ...shape, + blend: true, + color: shape.visible ? "black" : editPattern, + }, + context, + gridSize, + width, + height + ); + } + if (drawingShape) { + drawShape(drawingShape, context, gridSize, width, height); + } + if (hoveredShape) { + const shape = { ...hoveredShape, color: "#BB99FF", blend: true }; + drawShape(shape, context, gridSize, width, height); + } + } else { + // Not editing + for (let shape of shapes) { + if (shape.visible) { + drawShape(shape, context, gridSize, width, height); + } + } + } + hoveredShapeRef.current = hoveredShape; + } + }, [ + shapes, + width, + height, + pointerPosition, + isEditing, + drawingShape, + gridSize, + shouldHover, + ]); + + return ( +
+ +
+ ); +} + +export default MapFog; diff --git a/src/components/map/MapInteraction.js b/src/components/map/MapInteraction.js new file mode 100644 index 0000000..06e0cd0 --- /dev/null +++ b/src/components/map/MapInteraction.js @@ -0,0 +1,168 @@ +import React, { useRef, useEffect } from "react"; +import { Box } from "theme-ui"; +import interact from "interactjs"; +import normalizeWheel from "normalize-wheel"; + +import { MapInteractionProvider } from "../../contexts/MapInteractionContext"; + +import LoadingOverlay from "../LoadingOverlay"; + +const zoomSpeed = -0.001; +const minZoom = 0.1; +const maxZoom = 5; + +function MapInteraction({ + map, + aspectRatio, + isEnabled, + children, + controls, + loading, +}) { + const mapContainerRef = useRef(); + const mapMoveContainerRef = useRef(); + const mapTranslateRef = useRef({ x: 0, y: 0 }); + const mapScaleRef = useRef(1); + function setTranslateAndScale(newTranslate, newScale) { + const moveContainer = mapMoveContainerRef.current; + moveContainer.style.transform = `translate(${newTranslate.x}px, ${newTranslate.y}px) scale(${newScale})`; + mapScaleRef.current = newScale; + mapTranslateRef.current = newTranslate; + } + + useEffect(() => { + function handleMove(event, isGesture) { + const scale = mapScaleRef.current; + const translate = mapTranslateRef.current; + + let newScale = scale; + let newTranslate = translate; + + if (isGesture) { + newScale = Math.max(Math.min(scale + event.ds, maxZoom), minZoom); + } + + if (isEnabled || isGesture) { + newTranslate = { + x: translate.x + event.dx, + y: translate.y + event.dy, + }; + } + setTranslateAndScale(newTranslate, newScale); + } + const mapInteract = interact(".map") + .gesturable({ + listeners: { + move: (e) => handleMove(e, true), + }, + }) + .draggable({ + inertia: true, + listeners: { + move: (e) => handleMove(e, false), + }, + cursorChecker: () => { + return isEnabled && map ? "move" : "default"; + }, + }) + .on("doubletap", (event) => { + event.preventDefault(); + if (isEnabled) { + setTranslateAndScale({ x: 0, y: 0 }, 1); + } + }); + + return () => { + mapInteract.unset(); + }; + }, [isEnabled, map]); + + // Reset map transform when map changes + useEffect(() => { + setTranslateAndScale({ x: 0, y: 0 }, 1); + }, [map]); + + // Bind the wheel event of the map via a ref + // in order to support non-passive event listening + // to allow the track pad zoom to be interrupted + // see https://github.com/facebook/react/issues/14856 + useEffect(() => { + const mapContainer = mapContainerRef.current; + + function handleZoom(event) { + // Stop overscroll on chrome and safari + // also stop pinch to zoom on chrome + event.preventDefault(); + + // Try and normalize the wheel event to prevent OS differences for zoom speed + const normalized = normalizeWheel(event); + + const scale = mapScaleRef.current; + const translate = mapTranslateRef.current; + + const deltaY = normalized.pixelY * zoomSpeed; + const newScale = Math.max(Math.min(scale + deltaY, maxZoom), minZoom); + + setTranslateAndScale(translate, newScale); + } + + if (mapContainer) { + mapContainer.addEventListener("wheel", handleZoom, { + passive: false, + }); + } + + return () => { + if (mapContainer) { + mapContainer.removeEventListener("wheel", handleZoom); + } + }; + }, []); + + return ( + + + + + + {children} + + + + {controls} + {loading && } + + ); +} + +export default MapInteraction; diff --git a/src/components/MapMenu.js b/src/components/map/MapMenu.js similarity index 100% rename from src/components/MapMenu.js rename to src/components/map/MapMenu.js diff --git a/src/components/map/MapSettings.js b/src/components/map/MapSettings.js new file mode 100644 index 0000000..230d327 --- /dev/null +++ b/src/components/map/MapSettings.js @@ -0,0 +1,126 @@ +import React from "react"; +import { Flex, Box, Label, Input, Checkbox, IconButton } from "theme-ui"; + +import ExpandMoreIcon from "../../icons/ExpandMoreIcon"; + +function MapSettings({ + map, + mapState, + onSettingsChange, + onStateSettingsChange, + showMore, + onShowMoreChange, +}) { + function handleFlagChange(event, flag) { + if (event.target.checked) { + onStateSettingsChange("editFlags", [...mapState.editFlags, flag]); + } else { + onStateSettingsChange( + "editFlags", + mapState.editFlags.filter((f) => f !== flag) + ); + } + } + + return ( + + + + + + onSettingsChange("gridX", parseInt(e.target.value)) + } + disabled={map === null || map.type === "default"} + min={1} + my={1} + /> + + + + + onSettingsChange("gridY", parseInt(e.target.value)) + } + disabled={map === null || map.type === "default"} + min={1} + my={1} + /> + + + {showMore && ( + <> + + + + + + + + + + + onSettingsChange("name", e.target.value)} + disabled={map === null || map.type === "default"} + my={1} + /> + + + )} + { + e.stopPropagation(); + e.preventDefault(); + onShowMoreChange(!showMore); + }} + sx={{ + transform: `rotate(${showMore ? "180deg" : "0"})`, + alignSelf: "center", + }} + aria-label={showMore ? "Show Less" : "Show More"} + title={showMore ? "Show Less" : "Show More"} + disabled={map === null} + > + + + + ); +} + +export default MapSettings; diff --git a/src/components/map/MapTile.js b/src/components/map/MapTile.js new file mode 100644 index 0000000..38c4989 --- /dev/null +++ b/src/components/map/MapTile.js @@ -0,0 +1,173 @@ +import React, { useState } from "react"; +import { Flex, Image as UIImage, IconButton, Box, Text } from "theme-ui"; + +import RemoveMapIcon from "../../icons/RemoveMapIcon"; +import ResetMapIcon from "../../icons/ResetMapIcon"; +import ExpandMoreDotIcon from "../../icons/ExpandMoreDotIcon"; + +import useDataSource from "../../helpers/useDataSource"; +import { mapSources as defaultMapSources } from "../../maps"; + +function MapTile({ + map, + mapState, + isSelected, + onMapSelect, + onMapRemove, + onMapReset, + onSubmit, +}) { + const mapSource = useDataSource(map, defaultMapSources); + const [isMapTileMenuOpen, setIsTileMenuOpen] = useState(false); + const isDefault = map.type === "default"; + const hasMapState = + mapState && + (Object.values(mapState.tokens).length > 0 || + mapState.mapDrawActions.length > 0 || + mapState.fogDrawActions.length > 0); + + const expandButton = ( + { + e.preventDefault(); + e.stopPropagation(); + setIsTileMenuOpen(true); + }} + bg="overlay" + sx={{ borderRadius: "50%" }} + m={1} + > + + + ); + + function removeButton(map) { + return ( + { + e.preventDefault(); + e.stopPropagation(); + setIsTileMenuOpen(false); + onMapRemove(map.id); + }} + bg="overlay" + sx={{ borderRadius: "50%" }} + m={1} + > + + + ); + } + + function resetButton(map) { + return ( + { + e.preventDefault(); + e.stopPropagation(); + setIsTileMenuOpen(false); + onMapReset(map.id); + }} + bg="overlay" + sx={{ borderRadius: "50%" }} + m={1} + > + + + ); + } + + return ( + { + setIsTileMenuOpen(false); + if (!isSelected) { + onMapSelect(map); + } + }} + onDoubleClick={(e) => { + if (!isMapTileMenuOpen) { + onSubmit(e); + } + }} + > + + + + {map.name} + + + {/* Show expand button only if both reset and remove is available */} + {isSelected && ( + + {isDefault && hasMapState && resetButton(map)} + {!isDefault && hasMapState && !isMapTileMenuOpen && expandButton} + {!isDefault && !hasMapState && removeButton(map)} + + )} + {/* Tile menu for two actions */} + {!isDefault && isMapTileMenuOpen && isSelected && ( + setIsTileMenuOpen(false)} + > + {!isDefault && removeButton(map)} + {hasMapState && resetButton(map)} + + )} + + ); +} + +export default MapTile; diff --git a/src/components/map/MapTiles.js b/src/components/map/MapTiles.js new file mode 100644 index 0000000..5babba3 --- /dev/null +++ b/src/components/map/MapTiles.js @@ -0,0 +1,75 @@ +import React from "react"; +import { Flex } from "theme-ui"; +import SimpleBar from "simplebar-react"; + +import AddIcon from "../../icons/AddIcon"; + +import MapTile from "./MapTile"; + +function MapTiles({ + maps, + selectedMap, + selectedMapState, + onMapSelect, + onMapAdd, + onMapRemove, + onMapReset, + onSubmit, +}) { + return ( + + + + + + {maps.map((map) => ( + + ))} + + + ); +} + +export default MapTiles; diff --git a/src/components/MapToken.js b/src/components/map/MapToken.js similarity index 54% rename from src/components/MapToken.js rename to src/components/map/MapToken.js index 0923478..6d8309a 100644 --- a/src/components/MapToken.js +++ b/src/components/map/MapToken.js @@ -1,12 +1,17 @@ import React, { useRef } from "react"; import { Box, Image } from "theme-ui"; -import TokenLabel from "./TokenLabel"; -import TokenStatus from "./TokenStatus"; +import TokenLabel from "../token/TokenLabel"; +import TokenStatus from "../token/TokenStatus"; -import usePreventTouch from "../helpers/usePreventTouch"; +import usePreventTouch from "../../helpers/usePreventTouch"; +import useDataSource from "../../helpers/useDataSource"; + +import { tokenSources } from "../../tokens"; + +function MapToken({ token, tokenState, tokenSizePercent, className }) { + const imageSource = useDataSource(token, tokenSources); -function MapToken({ token, tokenSizePercent, className }) { const imageRef = useRef(); // Stop touch to prevent 3d touch gesutre on iOS usePreventTouch(imageRef); @@ -14,7 +19,7 @@ function MapToken({ token, tokenSizePercent, className }) { return ( - {token.status && } - {token.label && } + {tokenState.statuses && ( + + )} + {tokenState.label && } diff --git a/src/components/map/SelectMapButton.js b/src/components/map/SelectMapButton.js new file mode 100644 index 0000000..2e9dc87 --- /dev/null +++ b/src/components/map/SelectMapButton.js @@ -0,0 +1,41 @@ +import React, { useState } from "react"; +import { IconButton } from "theme-ui"; + +import SelectMapModal from "../../modals/SelectMapModal"; +import SelectMapIcon from "../../icons/SelectMapIcon"; + +function SelectMapButton({ onMapChange, onMapStateChange, currentMap }) { + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + function openModal() { + setIsAddModalOpen(true); + } + function closeModal() { + setIsAddModalOpen(false); + } + + function handleDone() { + closeModal(); + } + + return ( + <> + + + + + + ); +} + +export default SelectMapButton; diff --git a/src/components/map/controls/AlphaBlendToggle.js b/src/components/map/controls/AlphaBlendToggle.js new file mode 100644 index 0000000..ccc2065 --- /dev/null +++ b/src/components/map/controls/AlphaBlendToggle.js @@ -0,0 +1,19 @@ +import React from "react"; +import { IconButton } from "theme-ui"; + +import BlendOnIcon from "../../../icons/BlendOnIcon"; +import BlendOffIcon from "../../../icons/BlendOffIcon"; + +function AlphaBlendToggle({ useBlending, onBlendingChange }) { + return ( + onBlendingChange(!useBlending)} + > + {useBlending ? : } + + ); +} + +export default AlphaBlendToggle; diff --git a/src/components/map/controls/BrushToolSettings.js b/src/components/map/controls/BrushToolSettings.js new file mode 100644 index 0000000..df24bb1 --- /dev/null +++ b/src/components/map/controls/BrushToolSettings.js @@ -0,0 +1,61 @@ +import React from "react"; +import { Flex } from "theme-ui"; + +import ColorControl from "./ColorControl"; +import AlphaBlendToggle from "./AlphaBlendToggle"; +import RadioIconButton from "./RadioIconButton"; + +import BrushStrokeIcon from "../../../icons/BrushStrokeIcon"; +import BrushFillIcon from "../../../icons/BrushFillIcon"; + +import UndoButton from "./UndoButton"; +import RedoButton from "./RedoButton"; + +import Divider from "./Divider"; + +function BrushToolSettings({ + settings, + onSettingChange, + onToolAction, + disabledActions, +}) { + return ( + + onSettingChange({ color })} + /> + + onSettingChange({ type: "stroke" })} + isSelected={settings.type === "stroke"} + > + + + onSettingChange({ type: "fill" })} + isSelected={settings.type === "fill"} + > + + + + onSettingChange({ useBlending })} + /> + + onToolAction("mapUndo")} + disabled={disabledActions.includes("undo")} + /> + onToolAction("mapRedo")} + disabled={disabledActions.includes("redo")} + /> + + ); +} + +export default BrushToolSettings; diff --git a/src/components/map/controls/ColorControl.js b/src/components/map/controls/ColorControl.js new file mode 100644 index 0000000..d9264f1 --- /dev/null +++ b/src/components/map/controls/ColorControl.js @@ -0,0 +1,107 @@ +import React, { useState } from "react"; +import { Box } from "theme-ui"; + +import colors, { colorOptions } from "../../../helpers/colors"; +import MapMenu from "../MapMenu"; + +function ColorCircle({ color, selected, onClick, sx }) { + return ( + + {selected && ( + + )} + + ); +} + +function ColorControl({ color, onColorChange }) { + const [showColorMenu, setShowColorMenu] = useState(false); + const [colorMenuOptions, setColorMenuOptions] = useState({}); + + function handleControlClick(event) { + if (showColorMenu) { + setShowColorMenu(false); + setColorMenuOptions({}); + } else { + setShowColorMenu(true); + const rect = event.target.getBoundingClientRect(); + setColorMenuOptions({ + // Align the right of the submenu to the left of the tool and center vertically + left: `${rect.left + rect.width / 2}px`, + top: `${rect.bottom + 16}px`, + style: { transform: "translateX(-50%)" }, + // Exclude this node from the sub menus auto close + excludeNode: event.target, + }); + } + } + + const colorMenu = ( + { + setShowColorMenu(false); + setColorMenuOptions({}); + }} + {...colorMenuOptions} + > + + {colorOptions.map((c) => ( + { + onColorChange(c); + setShowColorMenu(false); + setColorMenuOptions({}); + }} + sx={{ width: "25%", paddingTop: "25%" }} + /> + ))} + + + ); + + return ( + <> + + {colorMenu} + + ); +} + +export default ColorControl; diff --git a/src/components/map/controls/Divider.js b/src/components/map/controls/Divider.js new file mode 100644 index 0000000..485e2d6 --- /dev/null +++ b/src/components/map/controls/Divider.js @@ -0,0 +1,24 @@ +import React from "react"; +import { Divider } from "theme-ui"; + +function StyledDivider({ vertical }) { + return ( + + ); +} + +StyledDivider.defaultProps = { + vertical: false, +}; + +export default StyledDivider; diff --git a/src/components/map/controls/EdgeSnappingToggle.js b/src/components/map/controls/EdgeSnappingToggle.js new file mode 100644 index 0000000..addd219 --- /dev/null +++ b/src/components/map/controls/EdgeSnappingToggle.js @@ -0,0 +1,21 @@ +import React from "react"; +import { IconButton } from "theme-ui"; + +import SnappingOnIcon from "../../../icons/SnappingOnIcon"; +import SnappingOffIcon from "../../../icons/SnappingOffIcon"; + +function EdgeSnappingToggle({ useEdgeSnapping, onEdgeSnappingChange }) { + return ( + onEdgeSnappingChange(!useEdgeSnapping)} + > + {useEdgeSnapping ? : } + + ); +} + +export default EdgeSnappingToggle; diff --git a/src/components/map/controls/EraseToolSettings.js b/src/components/map/controls/EraseToolSettings.js new file mode 100644 index 0000000..0660570 --- /dev/null +++ b/src/components/map/controls/EraseToolSettings.js @@ -0,0 +1,34 @@ +import React from "react"; +import { Flex, IconButton } from "theme-ui"; + +import EraseAllIcon from "../../../icons/EraseAllIcon"; + +import UndoButton from "./UndoButton"; +import RedoButton from "./RedoButton"; + +import Divider from "./Divider"; + +function EraseToolSettings({ onToolAction, disabledActions }) { + return ( + + onToolAction("eraseAll")} + > + + + + onToolAction("mapUndo")} + disabled={disabledActions.includes("undo")} + /> + onToolAction("mapRedo")} + disabled={disabledActions.includes("redo")} + /> + + ); +} + +export default EraseToolSettings; diff --git a/src/components/map/controls/FogToolSettings.js b/src/components/map/controls/FogToolSettings.js new file mode 100644 index 0000000..04183d3 --- /dev/null +++ b/src/components/map/controls/FogToolSettings.js @@ -0,0 +1,72 @@ +import React from "react"; +import { Flex } from "theme-ui"; + +import EdgeSnappingToggle from "./EdgeSnappingToggle"; +import RadioIconButton from "./RadioIconButton"; +import GridSnappingToggle from "./GridSnappingToggle"; + +import FogAddIcon from "../../../icons/FogAddIcon"; +import FogRemoveIcon from "../../../icons/FogRemoveIcon"; +import FogToggleIcon from "../../../icons/FogToggleIcon"; + +import UndoButton from "./UndoButton"; +import RedoButton from "./RedoButton"; + +import Divider from "./Divider"; + +function BrushToolSettings({ + settings, + onSettingChange, + onToolAction, + disabledActions, +}) { + return ( + + onSettingChange({ type: "add" })} + isSelected={settings.type === "add"} + > + + + onSettingChange({ type: "remove" })} + isSelected={settings.type === "remove"} + > + + + onSettingChange({ type: "toggle" })} + isSelected={settings.type === "toggle"} + > + + + + + onSettingChange({ useEdgeSnapping }) + } + /> + + onSettingChange({ useGridSnapping }) + } + /> + + onToolAction("fogUndo")} + disabled={disabledActions.includes("undo")} + /> + onToolAction("fogRedo")} + disabled={disabledActions.includes("redo")} + /> + + ); +} + +export default BrushToolSettings; diff --git a/src/components/map/controls/GridSnappingToggle.js b/src/components/map/controls/GridSnappingToggle.js new file mode 100644 index 0000000..b09232a --- /dev/null +++ b/src/components/map/controls/GridSnappingToggle.js @@ -0,0 +1,21 @@ +import React from "react"; +import { IconButton } from "theme-ui"; + +import GridOnIcon from "../../../icons/GridOnIcon"; +import GridOffIcon from "../../../icons/GridOffIcon"; + +function GridSnappingToggle({ useGridSnapping, onGridSnappingChange }) { + return ( + onGridSnappingChange(!useGridSnapping)} + > + {useGridSnapping ? : } + + ); +} + +export default GridSnappingToggle; diff --git a/src/components/map/controls/RadioIconButton.js b/src/components/map/controls/RadioIconButton.js new file mode 100644 index 0000000..14579bc --- /dev/null +++ b/src/components/map/controls/RadioIconButton.js @@ -0,0 +1,22 @@ +import React from "react"; +import { IconButton } from "theme-ui"; + +function RadioIconButton({ title, onClick, isSelected, disabled, children }) { + return ( + + {children} + + ); +} + +RadioIconButton.defaultProps = { + disabled: false, +}; + +export default RadioIconButton; diff --git a/src/components/map/controls/RedoButton.js b/src/components/map/controls/RedoButton.js new file mode 100644 index 0000000..e233143 --- /dev/null +++ b/src/components/map/controls/RedoButton.js @@ -0,0 +1,14 @@ +import React from "react"; +import { IconButton } from "theme-ui"; + +import RedoIcon from "../../../icons/RedoIcon"; + +function RedoButton({ onClick, disabled }) { + return ( + + + + ); +} + +export default RedoButton; diff --git a/src/components/map/controls/ShapeToolSettings.js b/src/components/map/controls/ShapeToolSettings.js new file mode 100644 index 0000000..e4a8328 --- /dev/null +++ b/src/components/map/controls/ShapeToolSettings.js @@ -0,0 +1,69 @@ +import React from "react"; +import { Flex } from "theme-ui"; + +import ColorControl from "./ColorControl"; +import AlphaBlendToggle from "./AlphaBlendToggle"; +import RadioIconButton from "./RadioIconButton"; + +import ShapeRectangleIcon from "../../../icons/ShapeRectangleIcon"; +import ShapeCircleIcon from "../../../icons/ShapeCircleIcon"; +import ShapeTriangleIcon from "../../../icons/ShapeTriangleIcon"; + +import UndoButton from "./UndoButton"; +import RedoButton from "./RedoButton"; + +import Divider from "./Divider"; + +function ShapeToolSettings({ + settings, + onSettingChange, + onToolAction, + disabledActions, +}) { + return ( + + onSettingChange({ color })} + /> + + onSettingChange({ type: "rectangle" })} + isSelected={settings.type === "rectangle"} + > + + + onSettingChange({ type: "circle" })} + isSelected={settings.type === "circle"} + > + + + onSettingChange({ type: "triangle" })} + isSelected={settings.type === "triangle"} + > + + + + onSettingChange({ useBlending })} + /> + + onToolAction("mapUndo")} + disabled={disabledActions.includes("undo")} + /> + onToolAction("mapRedo")} + disabled={disabledActions.includes("redo")} + /> + + ); +} + +export default ShapeToolSettings; diff --git a/src/components/map/controls/UndoButton.js b/src/components/map/controls/UndoButton.js new file mode 100644 index 0000000..88d0d26 --- /dev/null +++ b/src/components/map/controls/UndoButton.js @@ -0,0 +1,14 @@ +import React from "react"; +import { IconButton } from "theme-ui"; + +import UndoIcon from "../../../icons/UndoIcon"; + +function UndoButton({ onClick, disabled }) { + return ( + + + + ); +} + +export default UndoButton; diff --git a/src/components/AddPartyMemberButton.js b/src/components/party/AddPartyMemberButton.js similarity index 84% rename from src/components/AddPartyMemberButton.js rename to src/components/party/AddPartyMemberButton.js index b26dd7f..b2932b1 100644 --- a/src/components/AddPartyMemberButton.js +++ b/src/components/party/AddPartyMemberButton.js @@ -1,8 +1,8 @@ import React, { useState } from "react"; import { IconButton } from "theme-ui"; -import AddPartyMemberModal from "../modals/AddPartyMemberModal"; -import AddPartyMemberIcon from "../icons/AddPartyMemberIcon"; +import AddPartyMemberModal from "../../modals/AddPartyMemberModal"; +import AddPartyMemberIcon from "../../icons/AddPartyMemberIcon"; function AddPartyMemberButton({ gameId }) { const [isAddModalOpen, setIsAddModalOpen] = useState(false); diff --git a/src/components/ChangeNicknameButton.js b/src/components/party/ChangeNicknameButton.js similarity index 75% rename from src/components/ChangeNicknameButton.js rename to src/components/party/ChangeNicknameButton.js index 4b4194b..20231af 100644 --- a/src/components/ChangeNicknameButton.js +++ b/src/components/party/ChangeNicknameButton.js @@ -1,8 +1,8 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { IconButton } from "theme-ui"; -import ChangeNicknameModal from "../modals/ChangeNicknameModal"; -import ChangeNicknameIcon from "../icons/ChangeNicknameIcon"; +import ChangeNicknameModal from "../../modals/ChangeNicknameModal"; +import ChangeNicknameIcon from "../../icons/ChangeNicknameIcon"; function ChangeNicknameButton({ nickname, onChange }) { const [isChangeModalOpen, setIsChangeModalOpen] = useState(false); @@ -13,7 +13,12 @@ function ChangeNicknameButton({ nickname, onChange }) { setIsChangeModalOpen(false); } - const [changedNickname, setChangedNickname] = useState(nickname); + const [changedNickname, setChangedNickname] = useState(""); + + useEffect(() => { + setChangedNickname(nickname); + }, [nickname]); + function handleChangeSubmit(event) { event.preventDefault(); onChange(changedNickname); diff --git a/src/components/Nickname.js b/src/components/party/Nickname.js similarity index 100% rename from src/components/Nickname.js rename to src/components/party/Nickname.js diff --git a/src/components/Party.js b/src/components/party/Party.js similarity index 95% rename from src/components/Party.js rename to src/components/party/Party.js index 59329d1..e2c436d 100644 --- a/src/components/Party.js +++ b/src/components/party/Party.js @@ -5,6 +5,7 @@ import AddPartyMemberButton from "./AddPartyMemberButton"; import Nickname from "./Nickname"; import ChangeNicknameButton from "./ChangeNicknameButton"; import StartStreamButton from "./StartStreamButton"; +import SettingsButton from "../SettingsButton"; function Party({ nickname, @@ -54,12 +55,13 @@ function Party({ + - + ); diff --git a/src/components/StartStreamButton.js b/src/components/party/StartStreamButton.js similarity index 96% rename from src/components/StartStreamButton.js rename to src/components/party/StartStreamButton.js index 12e4b15..b55020f 100644 --- a/src/components/StartStreamButton.js +++ b/src/components/party/StartStreamButton.js @@ -2,9 +2,9 @@ import React, { useState } from "react"; import { IconButton, Box, Text } from "theme-ui"; import adapter from "webrtc-adapter"; -import Link from "../components/Link"; +import Link from "../Link"; -import StartStreamModal from "../modals/StartStreamModal"; +import StartStreamModal from "../../modals/StartStreamModal"; function StartStreamButton({ onStreamStart, onStreamEnd, stream }) { const [isStreamModalOpoen, setIsStreamModalOpen] = useState(false); diff --git a/src/components/Stream.js b/src/components/party/Stream.js similarity index 99% rename from src/components/Stream.js rename to src/components/party/Stream.js index 4583fea..beccd0a 100644 --- a/src/components/Stream.js +++ b/src/components/party/Stream.js @@ -1,7 +1,7 @@ import React, { useState, useRef, useEffect } from "react"; import { Text, IconButton, Box } from "theme-ui"; -import Banner from "./Banner"; +import Banner from "../Banner"; function Stream({ stream, nickname }) { const [streamMuted, setStreamMuted] = useState(false); diff --git a/src/components/token/ListToken.js b/src/components/token/ListToken.js new file mode 100644 index 0000000..bbdb3a2 --- /dev/null +++ b/src/components/token/ListToken.js @@ -0,0 +1,30 @@ +import React, { useRef } from "react"; +import { Box, Image } from "theme-ui"; + +import usePreventTouch from "../../helpers/usePreventTouch"; +import useDataSource from "../../helpers/useDataSource"; + +import { tokenSources } from "../../tokens"; + +function ListToken({ token, className }) { + const imageSource = useDataSource(token, tokenSources); + + const imageRef = useRef(); + // Stop touch to prevent 3d touch gesutre on iOS + usePreventTouch(imageRef); + + return ( + + + + ); +} + +export default ListToken; diff --git a/src/components/ProxyToken.js b/src/components/token/ProxyToken.js similarity index 68% rename from src/components/ProxyToken.js rename to src/components/token/ProxyToken.js index f10c81c..0ed984d 100644 --- a/src/components/ProxyToken.js +++ b/src/components/token/ProxyToken.js @@ -3,19 +3,46 @@ import ReactDOM from "react-dom"; import { Image, Box } from "theme-ui"; import interact from "interactjs"; -import usePortal from "../helpers/usePortal"; +import usePortal from "../../helpers/usePortal"; import TokenLabel from "./TokenLabel"; import TokenStatus from "./TokenStatus"; -function ProxyToken({ tokenClassName, onProxyDragEnd }) { +/** + * @callback onProxyDragEnd + * @param {boolean} isOnMap whether the token was dropped on the map + * @param {Object} token the token that was dropped + */ + +/** + * + * @param {string} tokenClassName The class name to attach the interactjs handler to + * @param {onProxyDragEnd} onProxyDragEnd Called when the proxy token is dropped + * @param {Object} tokens An optional mapping of tokens to use as a base when calling OnProxyDragEnd + * @param {Object} disabledTokens An optional mapping of tokens that shouldn't allow movement + + */ +function ProxyToken({ + tokenClassName, + onProxyDragEnd, + tokens, + disabledTokens, +}) { const proxyContainer = usePortal("root"); const [imageSource, setImageSource] = useState(""); - const [label, setLabel] = useState(""); - const [status, setStatus] = useState(""); + const [tokenId, setTokenId] = useState(null); const proxyRef = useRef(); + // Store the tokens in a ref and access in the interactjs loop + // This is needed to stop interactjs from creating multiple listeners + const tokensRef = useRef(tokens); + const disabledTokensRef = useRef(disabledTokens); + useEffect(() => { + tokensRef.current = tokens; + disabledTokensRef.current = disabledTokens; + }, [tokens, disabledTokens]); + const proxyOnMap = useRef(false); useEffect(() => { @@ -23,11 +50,15 @@ function ProxyToken({ tokenClassName, onProxyDragEnd }) { listeners: { start: (event) => { let target = event.target; + const id = target.dataset.id; + if (id in disabledTokensRef.current) { + return; + } + // Hide the token and copy it's image to the proxy target.parentElement.style.opacity = "0.25"; setImageSource(target.src); - setLabel(target.dataset.label || ""); - setStatus(target.dataset.status || ""); + setTokenId(id); let proxy = proxyRef.current; if (proxy) { @@ -73,10 +104,14 @@ function ProxyToken({ tokenClassName, onProxyDragEnd }) { end: (event) => { let target = event.target; + const id = target.dataset.id; + if (id in disabledTokensRef.current) { + return; + } let proxy = proxyRef.current; if (proxy) { - if (onProxyDragEnd) { - const mapImage = document.querySelector(".mapImage"); + const mapImage = document.querySelector(".mapImage"); + if (onProxyDragEnd && mapImage) { const mapImageRect = mapImage.getBoundingClientRect(); let x = parseFloat(proxy.getAttribute("data-x")) || 0; @@ -88,13 +123,13 @@ function ProxyToken({ tokenClassName, onProxyDragEnd }) { x = x / (mapImageRect.right - mapImageRect.left); y = y / (mapImageRect.bottom - mapImageRect.top); - target.setAttribute("data-x", x); - target.setAttribute("data-y", y); + // Get the token from the supplied tokens if it exists + const token = tokensRef.current[id] || {}; onProxyDragEnd(proxyOnMap.current, { - image: target.src, - // Pass in props stored as data- in the dom node - ...target.dataset, + ...token, + x, + y, }); } @@ -140,12 +175,21 @@ function ProxyToken({ tokenClassName, onProxyDragEnd }) { width: "100%", }} /> - {status && } - {label && } + {tokens[tokenId] && tokens[tokenId].statuses && ( + + )} + {tokens[tokenId] && tokens[tokenId].label && ( + + )}
, proxyContainer ); } +ProxyToken.defaultProps = { + tokens: {}, + disabledTokens: {}, +}; + export default ProxyToken; diff --git a/src/components/TokenLabel.js b/src/components/token/TokenLabel.js similarity index 92% rename from src/components/TokenLabel.js rename to src/components/token/TokenLabel.js index 91ac687..b5d90fb 100644 --- a/src/components/TokenLabel.js +++ b/src/components/token/TokenLabel.js @@ -1,7 +1,7 @@ import React from "react"; import { Image, Box, Text } from "theme-ui"; -import tokenLabel from "../images/TokenLabel.png"; +import tokenLabel from "../../images/TokenLabel.png"; function TokenLabel({ label }) { return ( @@ -39,6 +39,7 @@ function TokenLabel({ label }) { verticalAlign: "middle", lineHeight: 1.4, }} + color="hsl(210, 50%, 96%)" > {label} diff --git a/src/components/TokenMenu.js b/src/components/token/TokenMenu.js similarity index 75% rename from src/components/TokenMenu.js rename to src/components/token/TokenMenu.js index dc8f1c1..d64063a 100644 --- a/src/components/TokenMenu.js +++ b/src/components/token/TokenMenu.js @@ -1,18 +1,39 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef } from "react"; import interact from "interactjs"; import { Box, Input } from "theme-ui"; -import MapMenu from "./MapMenu"; +import MapMenu from "../map/MapMenu"; -import colors, { colorOptions } from "../helpers/colors"; +import colors, { colorOptions } from "../../helpers/colors"; -function TokenMenu({ tokenClassName, onTokenChange }) { +/** + * @callback onTokenChange + * @param {Object} token the token that was changed + */ + +/** + * + * @param {string} tokenClassName The class name to attach the interactjs handler to + * @param {onProxyDragEnd} onTokenChange Called when the the token data is changed + * @param {Object} tokens An mapping of tokens to use as a base when calling onTokenChange + * @param {Object} disabledTokens An optional mapping of tokens that shouldn't allow interaction + */ +function TokenMenu({ tokenClassName, onTokenChange, tokens, disabledTokens }) { const [isOpen, setIsOpen] = useState(false); function handleRequestClose() { setIsOpen(false); } + // Store the tokens in a ref and access in the interactjs loop + // This is needed to stop interactjs from creating multiple listeners + const tokensRef = useRef(tokens); + const disabledTokensRef = useRef(disabledTokens); + useEffect(() => { + tokensRef.current = tokens; + disabledTokensRef.current = disabledTokens; + }, [tokens, disabledTokens]); + const [currentToken, setCurrentToken] = useState({}); const [menuLeft, setMenuLeft] = useState(0); const [menuTop, setMenuTop] = useState(0); @@ -31,30 +52,29 @@ function TokenMenu({ tokenClassName, onTokenChange }) { } function handleStatusChange(status) { - const statuses = - currentToken.status.split(" ").filter((s) => s !== "") || []; + const statuses = currentToken.statuses; let newStatuses = []; if (statuses.includes(status)) { newStatuses = statuses.filter((s) => s !== status); } else { newStatuses = [...statuses, status]; } - const newStatus = newStatuses.join(" "); setCurrentToken((prevToken) => ({ ...prevToken, - status: newStatus, + statuses: newStatuses, })); - onTokenChange({ ...currentToken, status: newStatus }); + onTokenChange({ ...currentToken, statuses: newStatuses }); } useEffect(() => { function handleTokenMenuOpen(event) { const target = event.target; - const dataset = (target && target.dataset) || {}; - setCurrentToken({ - image: target.src, - ...dataset, - }); + const id = target.getAttribute("data-id"); + if (id in disabledTokensRef.current) { + return; + } + const token = tokensRef.current[id] || {}; + setCurrentToken(token); const targetRect = target.getBoundingClientRect(); setMenuLeft(targetRect.left); @@ -162,7 +182,7 @@ function TokenMenu({ tokenClassName, onTokenChange }) { onClick={() => handleStatusChange(color)} aria-label={`Token label Color ${color}`} > - {currentToken.status && currentToken.status.includes(color) && ( + {currentToken.statuses && currentToken.statuses.includes(color) && ( - {Object.entries(tokens).map(([id, image]) => ( - - - + {tokens.map((token) => ( + ))} @@ -57,6 +65,7 @@ function Tokens({ onCreateMapToken }) { [token.id, token]))} /> ); diff --git a/src/contexts/AuthContext.js b/src/contexts/AuthContext.js index ace0b95..0d71b2c 100644 --- a/src/contexts/AuthContext.js +++ b/src/contexts/AuthContext.js @@ -1,4 +1,9 @@ import React, { useState, useEffect } from "react"; +import shortid from "shortid"; + +import { getRandomMonster } from "../helpers/monsters"; + +import db from "../database"; const AuthContext = React.createContext(); @@ -13,7 +18,48 @@ export function AuthProvider({ children }) { const [authenticationStatus, setAuthenticationStatus] = useState("unknown"); + const [userId, setUserId] = useState(); + useEffect(() => { + async function loadUserId() { + const storedUserId = await db.table("user").get("userId"); + if (storedUserId) { + setUserId(storedUserId.value); + } else { + const id = shortid.generate(); + setUserId(id); + db.table("user").add({ key: "userId", value: id }); + } + } + + loadUserId(); + }, []); + + const [nickname, setNickname] = useState(""); + useEffect(() => { + async function loadNickname() { + const storedNickname = await db.table("user").get("nickname"); + if (storedNickname) { + setNickname(storedNickname.value); + } else { + const name = getRandomMonster(); + setNickname(name); + db.table("user").add({ key: "nickname", value: name }); + } + } + + loadNickname(); + }, []); + + useEffect(() => { + if (nickname !== undefined) { + db.table("user").update("nickname", { value: nickname }); + } + }, [nickname]); + const value = { + userId, + nickname, + setNickname, password, setPassword, authenticationStatus, diff --git a/src/contexts/MapInteractionContext.js b/src/contexts/MapInteractionContext.js new file mode 100644 index 0000000..504c31e --- /dev/null +++ b/src/contexts/MapInteractionContext.js @@ -0,0 +1,9 @@ +import React from "react"; + +const MapInteractionContext = React.createContext({ + translateRef: null, + scaleRef: null, +}); +export const MapInteractionProvider = MapInteractionContext.Provider; + +export default MapInteractionContext; diff --git a/src/database.js b/src/database.js new file mode 100644 index 0000000..c1ad236 --- /dev/null +++ b/src/database.js @@ -0,0 +1,11 @@ +import Dexie from "dexie"; + +const db = new Dexie("OwlbearRodeoDB"); +db.version(1).stores({ + maps: "id, owner", + states: "mapId", + tokens: "id, owner", + user: "key", +}); + +export default db; diff --git a/src/helpers/Peer.js b/src/helpers/Peer.js index b3aa6e0..04d8893 100644 --- a/src/helpers/Peer.js +++ b/src/helpers/Peer.js @@ -1,8 +1,9 @@ import SimplePeer from "simple-peer"; import BinaryPack from "js-binarypack"; -import toBuffer from "blob-to-buffer"; import shortid from "shortid"; +import blobToBuffer from "./blobToBuffer"; + // Limit buffer size to 16kb to avoid issues with chrome packet size // http://viblast.com/blog/2015/2/5/webrtc-data-channel-message-size/ const MAX_BUFFER_SIZE = 16000; @@ -40,13 +41,9 @@ class Peer extends SimplePeer { }); } - sendPackedData(packedData) { - toBuffer(packedData, (error, buffer) => { - if (error) { - throw error; - } - super.send(buffer); - }); + async sendPackedData(packedData) { + const buffer = await blobToBuffer(packedData); + super.send(buffer); } send(data) { diff --git a/src/helpers/blobToBuffer.js b/src/helpers/blobToBuffer.js new file mode 100644 index 0000000..55eb6d7 --- /dev/null +++ b/src/helpers/blobToBuffer.js @@ -0,0 +1,13 @@ +async function blobToBuffer(blob) { + if (blob.arrayBuffer) { + const arrayBuffer = await blob.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + return buffer; + } else { + const arrayBuffer = await new Response(blob).arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + return buffer; + } +} + +export default blobToBuffer; diff --git a/src/helpers/drawing.js b/src/helpers/drawing.js new file mode 100644 index 0000000..89a192b --- /dev/null +++ b/src/helpers/drawing.js @@ -0,0 +1,361 @@ +import simplify from "simplify-js"; + +import * as Vector2 from "./vector2"; +import { toDegrees } from "./shared"; +import colors from "./colors"; + +const snappingThreshold = 1 / 5; +export function getBrushPositionForTool( + brushPosition, + tool, + toolSettings, + gridSize, + shapes +) { + let position = brushPosition; + if (tool === "shape") { + const snapped = Vector2.roundTo(position, gridSize); + const minGrid = Vector2.min(gridSize); + const distance = Vector2.length(Vector2.subtract(snapped, position)); + if (distance < minGrid * snappingThreshold) { + position = snapped; + } + } + if (tool === "fog" && toolSettings.type === "add") { + if (toolSettings.useGridSnapping) { + position = Vector2.roundTo(position, gridSize); + } + if (toolSettings.useEdgeSnapping) { + const minGrid = Vector2.min(gridSize); + let closestDistance = Number.MAX_VALUE; + let closestPosition = position; + // Find the closest point on all fog shapes + for (let shape of shapes) { + if (shape.type === "fog") { + const points = shape.data.points; + const isInShape = Vector2.pointInPolygon(position, points); + // Find the closest point to each line of the shape + for (let i = 0; i < points.length; i++) { + const a = points[i]; + // Wrap around points to the start to account for closed shape + const b = points[(i + 1) % points.length]; + + const { + distance: distanceToLine, + point: pointOnLine, + } = Vector2.distanceToLine(position, a, b); + const isCloseToShape = distanceToLine < minGrid * snappingThreshold; + if ( + (isInShape || isCloseToShape) && + distanceToLine < closestDistance + ) { + closestPosition = pointOnLine; + closestDistance = distanceToLine; + } + } + } + } + position = closestPosition; + } + } + + return position; +} + +export function getDefaultShapeData(type, brushPosition) { + if (type === "circle") { + return { x: brushPosition.x, y: brushPosition.y, radius: 0 }; + } else if (type === "rectangle") { + return { + x: brushPosition.x, + y: brushPosition.y, + width: 0, + height: 0, + }; + } else if (type === "triangle") { + return { + points: [ + { x: brushPosition.x, y: brushPosition.y }, + { x: brushPosition.x, y: brushPosition.y }, + { x: brushPosition.x, y: brushPosition.y }, + ], + }; + } +} + +export function getGridScale(gridSize) { + if (gridSize.x < gridSize.y) { + return { x: gridSize.y / gridSize.x, y: 1 }; + } else if (gridSize.y < gridSize.x) { + return { x: 1, y: gridSize.x / gridSize.y }; + } else { + return { x: 1, y: 1 }; + } +} + +export function getUpdatedShapeData(type, data, brushPosition, gridSize) { + const gridScale = getGridScale(gridSize); + if (type === "circle") { + const dif = Vector2.subtract(brushPosition, { + x: data.x, + y: data.y, + }); + const scaled = Vector2.multiply(dif, gridScale); + const distance = Vector2.length(scaled); + return { + ...data, + radius: distance, + }; + } else if (type === "rectangle") { + const dif = Vector2.subtract(brushPosition, { x: data.x, y: data.y }); + return { + ...data, + width: dif.x, + height: dif.y, + }; + } else if (type === "triangle") { + const points = data.points; + const dif = Vector2.subtract(brushPosition, points[0]); + // Scale the distance by the grid scale then unscale before adding + const scaled = Vector2.multiply(dif, gridScale); + const length = Vector2.length(scaled); + const direction = Vector2.normalize(scaled); + // Get the angle for a triangle who's width is the same as it's length + const angle = Math.atan(length / 2 / length); + const sideLength = length / Math.cos(angle); + + const leftDir = Vector2.rotateDirection(direction, toDegrees(angle)); + const rightDir = Vector2.rotateDirection(direction, -toDegrees(angle)); + + const leftDirUnscaled = Vector2.divide(leftDir, gridScale); + const rightDirUnscaled = Vector2.divide(rightDir, gridScale); + + return { + points: [ + points[0], + Vector2.add(Vector2.multiply(leftDirUnscaled, sideLength), points[0]), + Vector2.add(Vector2.multiply(rightDirUnscaled, sideLength), points[0]), + ], + }; + } +} + +const defaultStrokeSize = 1 / 10; +export function getStrokeSize(multiplier, gridSize, canvasWidth, canvasHeight) { + const gridPixelSize = Vector2.multiply(gridSize, { + x: canvasWidth, + y: canvasHeight, + }); + return Vector2.min(gridPixelSize) * defaultStrokeSize * multiplier; +} + +export function shapeHasFill(shape) { + return ( + shape.type === "fog" || + shape.type === "shape" || + (shape.type === "path" && shape.pathType === "fill") + ); +} + +export function pointsToQuadraticBezier(points) { + const quadraticPoints = []; + + // Draw a smooth curve between the points where each control point + // is the current point in the array and the next point is the center of + // the current point and the next point + for (let i = 1; i < points.length - 2; i++) { + const start = points[i - 1]; + const controlPoint = points[i]; + const next = points[i + 1]; + const end = Vector2.divide(Vector2.add(controlPoint, next), 2); + + quadraticPoints.push({ start, controlPoint, end }); + } + // Curve through the last two points + quadraticPoints.push({ + start: points[points.length - 2], + controlPoint: points[points.length - 1], + end: points[points.length - 1], + }); + + return quadraticPoints; +} + +export function pointsToPathSmooth(points, close, canvasWidth, canvasHeight) { + const path = new Path2D(); + if (points.length < 2) { + return path; + } + path.moveTo(points[0].x * canvasWidth, points[0].y * canvasHeight); + + const quadraticPoints = pointsToQuadraticBezier(points); + for (let quadPoint of quadraticPoints) { + const pointScaled = Vector2.multiply(quadPoint.end, { + x: canvasWidth, + y: canvasHeight, + }); + const controlScaled = Vector2.multiply(quadPoint.controlPoint, { + x: canvasWidth, + y: canvasHeight, + }); + path.quadraticCurveTo( + controlScaled.x, + controlScaled.y, + pointScaled.x, + pointScaled.y + ); + } + + if (close) { + path.closePath(); + } + return path; +} + +export function pointsToPathSharp(points, close, canvasWidth, canvasHeight) { + const path = new Path2D(); + path.moveTo(points[0].x * canvasWidth, points[0].y * canvasHeight); + for (let point of points.slice(1)) { + path.lineTo(point.x * canvasWidth, point.y * canvasHeight); + } + if (close) { + path.closePath(); + } + + return path; +} + +export function circleToPath(x, y, radius, canvasWidth, canvasHeight) { + const path = new Path2D(); + const minSide = canvasWidth < canvasHeight ? canvasWidth : canvasHeight; + path.arc( + x * canvasWidth, + y * canvasHeight, + radius * minSide, + 0, + 2 * Math.PI, + true + ); + return path; +} + +export function rectangleToPath( + x, + y, + width, + height, + canvasWidth, + canvasHeight +) { + const path = new Path2D(); + path.rect( + x * canvasWidth, + y * canvasHeight, + width * canvasWidth, + height * canvasHeight + ); + return path; +} + +export function shapeToPath(shape, canvasWidth, canvasHeight) { + const data = shape.data; + if (shape.type === "path") { + return pointsToPathSmooth( + data.points, + shape.pathType === "fill", + canvasWidth, + canvasHeight + ); + } else if (shape.type === "shape") { + if (shape.shapeType === "circle") { + return circleToPath( + data.x, + data.y, + data.radius, + canvasWidth, + canvasHeight + ); + } else if (shape.shapeType === "rectangle") { + return rectangleToPath( + data.x, + data.y, + data.width, + data.height, + canvasWidth, + canvasHeight + ); + } else if (shape.shapeType === "triangle") { + return pointsToPathSharp(data.points, true, canvasWidth, canvasHeight); + } + } else if (shape.type === "fog") { + return pointsToPathSharp( + shape.data.points, + true, + canvasWidth, + canvasHeight + ); + } +} + +export function isShapeHovered( + shape, + context, + hoverPosition, + canvasWidth, + canvasHeight +) { + const path = shapeToPath(shape, canvasWidth, canvasHeight); + if (shapeHasFill(shape)) { + return context.isPointInPath( + path, + hoverPosition.x * canvasWidth, + hoverPosition.y * canvasHeight + ); + } else { + return context.isPointInStroke( + path, + hoverPosition.x * canvasWidth, + hoverPosition.y * canvasHeight + ); + } +} + +export function drawShape(shape, context, gridSize, canvasWidth, canvasHeight) { + const path = shapeToPath(shape, canvasWidth, canvasHeight); + const color = colors[shape.color] || shape.color; + const fill = shapeHasFill(shape); + + context.globalAlpha = shape.blend ? 0.5 : 1.0; + context.fillStyle = color; + context.strokeStyle = color; + if (shape.strokeWidth > 0) { + context.lineCap = "round"; + context.lineWidth = getStrokeSize( + shape.strokeWidth, + gridSize, + canvasWidth, + canvasHeight + ); + context.stroke(path); + } + if (fill) { + context.fill(path); + } +} + +const defaultSimplifySize = 1 / 100; +export function simplifyPoints(points, gridSize, scale) { + return simplify( + points, + (Vector2.min(gridSize) * defaultSimplifySize) / scale + ); +} + +export function getRelativePointerPosition(event, container) { + if (container) { + const containerRect = container.getBoundingClientRect(); + const x = (event.clientX - containerRect.x) / containerRect.width; + const y = (event.clientY - containerRect.y) / containerRect.height; + return { x, y }; + } +} diff --git a/src/helpers/gestures.js b/src/helpers/gestures.js deleted file mode 100644 index 9efe6fb..0000000 --- a/src/helpers/gestures.js +++ /dev/null @@ -1,98 +0,0 @@ -import ShapeDetector from "shape-detector"; -import simplify from "simplify-js"; -import { normalize, subtract, dot, length } from "./vector2"; - -import gestures from "./gesturesData"; - -const detector = new ShapeDetector(gestures); - -export function pointsToGesture(points) { - return detector.spot(points).pattern; -} - -export function getBounds(points) { - let minX = Number.MAX_VALUE; - let maxX = Number.MIN_VALUE; - let minY = Number.MAX_VALUE; - let maxY = Number.MIN_VALUE; - for (let point of points) { - minX = point.x < minX ? point.x : minX; - maxX = point.x > maxX ? point.x : maxX; - minY = point.y < minY ? point.y : minY; - maxY = point.y > maxY ? point.y : maxY; - } - return { minX, maxX, minY, maxY }; -} - -function getTrianglePoints(points) { - if (points.length < 3) { - return points; - } - - // Simplify edges up to the average distance between points - let perimeterDistance = 0; - for (let i = 0; i < points.length - 1; i++) { - perimeterDistance += length(subtract(points[i + 1], points[i])); - } - const averagePointDistance = perimeterDistance / points.length; - const simplifiedPoints = simplify(points, averagePointDistance); - - const edges = []; - // Find edges of the simplified points that have the highest angular change - for (let i = 0; i < simplifiedPoints.length; i++) { - // Ensure index loops over to start and end of erray - const prevIndex = i - 1 < 0 ? simplifiedPoints.length - i - 1 : i - 1; - const nextIndex = (i + 1) % simplifiedPoints.length; - - const prev = normalize( - subtract(simplifiedPoints[i], simplifiedPoints[prevIndex]) - ); - const next = normalize( - subtract(simplifiedPoints[nextIndex], simplifiedPoints[i]) - ); - - const similarity = dot(prev, next); - if (similarity < 0.25) { - edges.push({ similarity, point: simplifiedPoints[i] }); - } - } - - edges.sort((a, b) => a.similarity - b.similarity); - const trianglePoints = edges.slice(0, 3).map((edge) => edge.point); - // Return the points with the highest angular change or fallback to a heuristic - if (trianglePoints.length === 3) { - return trianglePoints; - } else { - return [ - { x: points[0].x, y: points[0].y }, - { - x: points[Math.floor(points.length / 2)].x, - y: points[Math.floor(points.length / 2)].y, - }, - { x: points[points.length - 1].x, y: points[points.length - 1].y }, - ]; - } -} - -export function gestureToData(points, gesture) { - const bounds = getBounds(points); - const width = bounds.maxX - bounds.minX; - const height = bounds.maxY - bounds.minY; - const maxSide = width > height ? width : height; - switch (gesture) { - case "rectangle": - return { x: bounds.minX, y: bounds.minY, width, height }; - case "triangle": - return { - points: getTrianglePoints(points), - }; - case "circle": - return { - x: bounds.minX + width / 2, - y: bounds.minY + height / 2, - radius: maxSide / 2, - }; - default: - throw Error("Gesture not implemented"); - } -} diff --git a/src/helpers/gesturesData.js b/src/helpers/gesturesData.js deleted file mode 100644 index 1c5dc5a..0000000 --- a/src/helpers/gesturesData.js +++ /dev/null @@ -1,1444 +0,0 @@ -export default [ - { - points: [ - { x: 0.5197956577266922, y: 0.26954388894687403 }, - { x: 0.5197956577266922, y: 0.3309988518943743 }, - { x: 0.5210727969348659, y: 0.3283268969836134 }, - { x: 0.5274584929757343, y: 0.3256549420728525 }, - { x: 0.5644955300127714, y: 0.3256549420728525 }, - { x: 0.5632183908045977, y: 0.2762237762237762 }, - ], - name: "rectangle", - }, - { - points: [ - { x: 0.5223499361430396, y: 0.3643982882788853 }, - { x: 0.5312899106002554, y: 0.3643982882788853 }, - { x: 0.5325670498084292, y: 0.3657342657342657 }, - { x: 0.5530012771392082, y: 0.3684062206450266 }, - { x: 0.5708812260536399, y: 0.3684062206450266 }, - { x: 0.5708812260536399, y: 0.37375013046654837 }, - { x: 0.5696040868454662, y: 0.37375013046654837 }, - { x: 0.5696040868454662, y: 0.3777580628326897 }, - { x: 0.5670498084291188, y: 0.3831019726542115 }, - { x: 0.5670498084291188, y: 0.39646174720801586 }, - { x: 0.5644955300127714, y: 0.39913370211877675 }, - { x: 0.5644955300127714, y: 0.4071495668510594 }, - { x: 0.5274584929757343, y: 0.4071495668510594 }, - { x: 0.5261813537675607, y: 0.40180565702953763 }, - { x: 0.5223499361430396, y: 0.39913370211877675 }, - { x: 0.5197956577266922, y: 0.3884458824757332 }, - ], - name: "rectangle", - }, - { - points: [ - { x: 0.4355044699872286, y: 0.4111574992172007 }, - { x: 0.4355044699872286, y: 0.460588665066277 }, - { x: 0.438058748403576, y: 0.4579167101555161 }, - { x: 0.47509578544061304, y: 0.4579167101555161 }, - { x: 0.4763729246487867, y: 0.4525728003339944 }, - { x: 0.4763729246487867, y: 0.4124934766725811 }, - { x: 0.47509578544061304, y: 0.4124934766725811 }, - { x: 0.47509578544061304, y: 0.40581358939567896 }, - { x: 0.47126436781609193, y: 0.4044776119402985 }, - { x: 0.47126436781609193, y: 0.40581358939567896 }, - { x: 0.4648786717752235, y: 0.40848554430643985 }, - { x: 0.44572158365261816, y: 0.40848554430643985 }, - ], - name: "rectangle", - }, - { - points: [ - { x: 0.3499361430395913, y: 0.5420832898444838 }, - { x: 0.43039591315453385, y: 0.5420832898444838 }, - { x: 0.43167305236270753, y: 0.5434192672998643 }, - { x: 0.43167305236270753, y: 0.5915144556935602 }, - { x: 0.36398467432950193, y: 0.5915144556935602 }, - { x: 0.36270753512132825, y: 0.5901784782381797 }, - { x: 0.34738186462324394, y: 0.5901784782381797 }, - ], - name: "rectangle", - }, - { - points: [ - { x: 0.25925925925925924, y: 0.4552447552447552 }, - { x: 0.21583652618135377, y: 0.4552447552447552 }, - { x: 0.21583652618135377, y: 0.4579167101555161 }, - { x: 0.21839080459770116, y: 0.460588665066277 }, - { x: 0.21839080459770116, y: 0.49398810145078803 }, - { x: 0.21966794380587484, y: 0.49799603381692936 }, - { x: 0.22094508301404853, y: 0.4966600563615489 }, - { x: 0.24393358876117496, y: 0.4966600563615489 }, - { x: 0.24393358876117496, y: 0.49799603381692936 }, - { x: 0.24904214559386972, y: 0.4993320112723098 }, - { x: 0.26436781609195403, y: 0.4993320112723098 }, - { x: 0.26436781609195403, y: 0.5006679887276902 }, - ], - name: "rectangle", - }, - { - points: [ - { x: 0.26053639846743293, y: 0.22812858783008036 }, - { x: 0.26053639846743293, y: 0.3283268969836134 }, - { x: 0.26181353767560667, y: 0.32966287443899384 }, - { x: 0.26181353767560667, y: 0.3403506940820374 }, - { x: 0.26309067688378035, y: 0.3456946039035591 }, - { x: 0.26436781609195403, y: 0.3456946039035591 }, - { x: 0.26436781609195403, y: 0.34970253626970044 }, - { x: 0.2656449553001277, y: 0.34970253626970044 }, - { x: 0.2669220945083014, y: 0.360390355912744 }, - { x: 0.26947637292464877, y: 0.360390355912744 }, - { x: 0.26947637292464877, y: 0.35905437845736354 }, - { x: 0.2835249042145594, y: 0.35905437845736354 }, - { x: 0.28991060025542786, y: 0.36172633336812443 }, - { x: 0.31800766283524906, y: 0.3630623108235049 }, - { x: 0.31928480204342274, y: 0.3643982882788853 }, - { x: 0.32950191570881227, y: 0.3643982882788853 }, - { x: 0.33077905491698595, y: 0.3657342657342657 }, - { x: 0.3448275862068966, y: 0.3657342657342657 }, - { x: 0.34355044699872284, y: 0.3309988518943743 }, - { x: 0.34610472541507026, y: 0.3176390773405699 }, - { x: 0.34610472541507026, y: 0.2588560693038305 }, - { x: 0.3448275862068966, y: 0.25484813693768915 }, - { x: 0.3448275862068966, y: 0.23347249765160213 }, - { x: 0.34355044699872284, y: 0.2321365201962217 }, - ], - name: "rectangle", - }, - { - points: [ - { x: 0.438058748403576, y: 0.1372821208642104 }, - { x: 0.438058748403576, y: 0.16132971506105834 }, - { x: 0.4355044699872286, y: 0.1733535121594823 }, - { x: 0.4355044699872286, y: 0.18136937689176494 }, - { x: 0.4342273307790549, y: 0.18270535434714538 }, - { x: 0.4342273307790549, y: 0.26687193403611315 }, - { x: 0.4355044699872286, y: 0.26954388894687403 }, - { x: 0.4355044699872286, y: 0.2829036635006784 }, - { x: 0.4355044699872286, y: 0.28156768604529797 }, - { x: 0.44061302681992337, y: 0.2788957311345371 }, - { x: 0.4725415070242657, y: 0.2788957311345371 }, - { x: 0.47381864623243936, y: 0.27755975367915664 }, - { x: 0.47381864623243936, y: 0.26954388894687403 }, - { x: 0.4725415070242657, y: 0.26954388894687403 }, - { x: 0.4725415070242657, y: 0.26152802421459137 }, - { x: 0.47126436781609193, y: 0.26152802421459137 }, - { x: 0.47381864623243936, y: 0.13060223358730821 }, - { x: 0.4725415070242657, y: 0.13193821104268866 }, - { x: 0.4610472541507024, y: 0.13193821104268866 }, - { x: 0.4610472541507024, y: 0.1332741884980691 }, - { x: 0.45721583652618136, y: 0.1332741884980691 }, - { x: 0.45721583652618136, y: 0.13461016595344955 }, - ], - name: "rectangle", - }, - { - points: [ - { x: 0.6079182630906769, y: 0.13594614340883 }, - { x: 0.6436781609195402, y: 0.13594614340883 }, - { x: 0.6462324393358876, y: 0.13461016595344955 }, - { x: 0.6564495530012772, y: 0.13461016595344955 }, - { x: 0.6628352490421456, y: 0.13193821104268866 }, - { x: 0.6998722860791826, y: 0.13193821104268866 }, - { x: 0.698595146871009, y: 0.17201753470410186 }, - { x: 0.6947637292464879, y: 0.17201753470410186 }, - { x: 0.6947637292464879, y: 0.17468948961486275 }, - { x: 0.6845466155810983, y: 0.17201753470410186 }, - { x: 0.6130268199233716, y: 0.17201753470410186 }, - { x: 0.6130268199233716, y: 0.1733535121594823 }, - { x: 0.6104725415070242, y: 0.1733535121594823 }, - ], - name: "rectangle", - }, - { - points: [ - { x: 0.6500638569604087, y: 0.2722158438576349 }, - { x: 0.6513409961685823, y: 0.31363114497442857 }, - { x: 0.685823754789272, y: 0.314967122429809 }, - { x: 0.685823754789272, y: 0.31630309988518945 }, - { x: 0.6947637292464879, y: 0.3176390773405699 }, - { x: 0.6934865900383141, y: 0.26954388894687403 }, - { x: 0.6590038314176245, y: 0.26954388894687403 }, - ], - name: "rectangle", - }, - { - points: [ - { x: 0.7420178799489144, y: 0.4031416344849181 }, - { x: 0.7841634738186463, y: 0.4031416344849181 }, - { x: 0.7841634738186463, y: 0.4044776119402985 }, - { x: 0.7879948914431673, y: 0.4044776119402985 }, - { x: 0.7879948914431673, y: 0.39913370211877675 }, - { x: 0.7854406130268199, y: 0.3911178373864941 }, - { x: 0.7841634738186463, y: 0.3911178373864941 }, - { x: 0.7828863346104725, y: 0.38176599519883103 }, - { x: 0.7816091954022989, y: 0.38176599519883103 }, - { x: 0.7816091954022989, y: 0.36707024318964615 }, - { x: 0.7777777777777778, y: 0.36707024318964615 }, - { x: 0.7777777777777778, y: 0.3657342657342657 }, - { x: 0.7586206896551724, y: 0.36707024318964615 }, - { x: 0.7573435504469987, y: 0.3657342657342657 }, - { x: 0.7509578544061303, y: 0.3657342657342657 }, - { x: 0.7509578544061303, y: 0.3643982882788853 }, - { x: 0.7394636015325671, y: 0.3643982882788853 }, - { x: 0.7381864623243933, y: 0.3630623108235049 }, - { x: 0.7381864623243933, y: 0.36707024318964615 }, - { x: 0.7407407407407407, y: 0.36974219810040704 }, - { x: 0.7407407407407407, y: 0.3724141530111679 }, - { x: 0.7420178799489144, y: 0.3724141530111679 }, - { x: 0.7432950191570882, y: 0.3804300177434506 }, - { x: 0.7445721583652618, y: 0.3804300177434506 }, - { x: 0.7445721583652618, y: 0.3924538148418745 }, - ], - name: "rectangle", - }, - { - points: [ - { x: 0.7407407407407407, y: 0.45390877778937483 }, - { x: 0.7407407407407407, y: 0.460588665066277 }, - { x: 0.7420178799489144, y: 0.460588665066277 }, - { x: 0.7407407407407407, y: 0.4993320112723098 }, - { x: 0.7432950191570882, y: 0.4966600563615489 }, - { x: 0.7458492975734355, y: 0.4966600563615489 }, - { x: 0.7458492975734355, y: 0.4953240789061685 }, - { x: 0.7701149425287356, y: 0.4953240789061685 }, - { x: 0.7701149425287356, y: 0.4966600563615489 }, - { x: 0.7803320561941252, y: 0.49799603381692936 }, - { x: 0.7803320561941252, y: 0.4899801690846467 }, - { x: 0.7790549169859514, y: 0.48864419162926626 }, - { x: 0.7803320561941252, y: 0.45925268761089655 }, - { x: 0.7790549169859514, y: 0.4579167101555161 }, - { x: 0.7484035759897829, y: 0.4579167101555161 }, - { x: 0.7484035759897829, y: 0.45658073270013566 }, - ], - name: "rectangle", - }, - { - points: [ - { x: 0.5210727969348659, y: 0.5875065233274188 }, - { x: 0.5210727969348659, y: 0.5460912222106252 }, - { x: 0.5236270753512133, y: 0.5460912222106252 }, - { x: 0.5325670498084292, y: 0.551435132032147 }, - { x: 0.5670498084291188, y: 0.5500991545767665 }, - { x: 0.5670498084291188, y: 0.5834985909612775 }, - { x: 0.565772669220945, y: 0.5848345684166579 }, - { x: 0.5542784163473818, y: 0.5848345684166579 }, - { x: 0.5491698595146871, y: 0.582162613505897 }, - { x: 0.5108556832694764, y: 0.582162613505897 }, - ], - name: "rectangle", - }, - { - points: [ - { x: 0.6513409961685823, y: 0.5420832898444838 }, - { x: 0.6960408684546615, y: 0.5407473123891035 }, - { x: 0.6960408684546615, y: 0.5474271996660056 }, - { x: 0.6973180076628352, y: 0.5487631771213861 }, - { x: 0.70242656449553, y: 0.582162613505897 }, - { x: 0.6717752234993615, y: 0.582162613505897 }, - { x: 0.6704980842911877, y: 0.5834985909612775 }, - { x: 0.6526181353767561, y: 0.5834985909612775 }, - { x: 0.6513409961685823, y: 0.582162613505897 }, - { x: 0.6500638569604087, y: 0.5594509967644296 }, - { x: 0.6513409961685823, y: 0.5594509967644296 }, - { x: 0.6526181353767561, y: 0.5554430643982883 }, - { x: 0.6538952745849298, y: 0.5460912222106252 }, - { x: 0.6564495530012772, y: 0.5367393800229621 }, - { x: 0.6577266922094508, y: 0.5367393800229621 }, - { x: 0.6577266922094508, y: 0.5300594927460599 }, - { x: 0.6590038314176245, y: 0.5300594927460599 }, - ], - name: "rectangle", - }, - { - points: [ - { x: 0.7841634738186463, y: 0.5875065233274188 }, - { x: 0.8275862068965517, y: 0.5875065233274188 }, - { x: 0.8275862068965517, y: 0.6369376891764952 }, - { x: 0.7969348659003831, y: 0.6369376891764952 }, - { x: 0.7816091954022989, y: 0.6409456215426365 }, - { x: 0.7816091954022989, y: 0.6262498695334516 }, - { x: 0.7790549169859514, y: 0.6235779146226907 }, - { x: 0.7777777777777778, y: 0.618234004801169 }, - { x: 0.7777777777777778, y: 0.5995303204258429 }, - { x: 0.7803320561941252, y: 0.6022022753366036 }, - ], - name: "rectangle", - }, - { - points: [ - { x: 0.7841634738186463, y: 0.6810249452040497 }, - { x: 0.7841634738186463, y: 0.7625195699822566 }, - { x: 0.7841634738186463, y: 0.7611835925268761 }, - { x: 0.7867177522349936, y: 0.7611835925268761 }, - { x: 0.789272030651341, y: 0.7585116376161152 }, - { x: 0.7879948914431673, y: 0.7224402463208434 }, - { x: 0.7905491698595147, y: 0.7104164492224194 }, - { x: 0.7918263090676884, y: 0.7104164492224194 }, - { x: 0.7905491698595147, y: 0.6796889677486693 }, - { x: 0.789272030651341, y: 0.6796889677486693 }, - ], - name: "rectangle", - }, - { - points: [ - { x: 0.5208333333333334, y: 0.36547049963984024 }, - { x: 0.5178902453232397, y: 0.36547049963984024 }, - { x: 0.514947157313146, y: 0.36547049963984024 }, - { x: 0.5120040693030523, y: 0.36547049963984024 }, - { x: 0.5090609812929586, y: 0.36547049963984024 }, - { x: 0.5061178932828649, y: 0.36547049963984024 }, - { x: 0.5031748052727713, y: 0.36547049963984024 }, - { x: 0.5002317172626776, y: 0.36547049963984024 }, - { x: 0.4972886292525838, y: 0.36547049963984024 }, - { x: 0.4943455412424901, y: 0.36547049963984024 }, - { x: 0.49140245323239634, y: 0.36547049963984024 }, - { x: 0.4884593652223026, y: 0.36547049963984024 }, - { x: 0.48551627721220886, y: 0.36547049963984024 }, - { x: 0.4825731892021151, y: 0.36547049963984024 }, - { x: 0.47996794871794873, y: 0.36547049963984024 }, - { x: 0.4799546861638081, y: 0.36513291253192154 }, - { x: 0.4798391521992856, y: 0.36219209309880446 }, - { x: 0.47972361823476306, y: 0.3592512736656874 }, - { x: 0.47960808427024054, y: 0.3563104542325703 }, - { x: 0.479492550305718, y: 0.3533696347994532 }, - { x: 0.4793770163411955, y: 0.35042881536633613 }, - { x: 0.479261482376673, y: 0.34748799593321905 }, - { x: 0.47914594841215047, y: 0.34454717650010197 }, - { x: 0.47903041444762795, y: 0.3416063570669849 }, - { x: 0.47891488048310543, y: 0.3386655376338678 }, - { x: 0.4787993465185829, y: 0.3357247182007507 }, - { x: 0.4786838125540604, y: 0.33278389876763365 }, - { x: 0.4785682785895379, y: 0.32984307933451656 }, - { x: 0.47845274462501536, y: 0.3269022599013995 }, - { x: 0.47833721066049284, y: 0.3239614404682824 }, - { x: 0.4782216766959703, y: 0.3210206210351653 }, - { x: 0.47810614273144786, y: 0.31807980160204824 }, - { x: 0.47799060876692534, y: 0.31513898216893116 }, - { x: 0.4778750748024029, y: 0.3121981627358141 }, - { x: 0.47775954083788036, y: 0.309257343302697 }, - { x: 0.4776440068733579, y: 0.3063165238695799 }, - { x: 0.4775641025641026, y: 0.3042826272018859 }, - { x: 0.4775641025641026, y: 0.3033750048273396 }, - { x: 0.4775641025641026, y: 0.30043191681724585 }, - { x: 0.4775641025641026, y: 0.2974888288071521 }, - { x: 0.4775641025641026, y: 0.29454574079705836 }, - { x: 0.4775641025641026, y: 0.2916026527869646 }, - { x: 0.4775641025641026, y: 0.2886595647768709 }, - { x: 0.4775641025641026, y: 0.28571647676677714 }, - { x: 0.4775641025641026, y: 0.2827733887566834 }, - { x: 0.4775641025641026, y: 0.27983030074658966 }, - { x: 0.4775641025641026, y: 0.2768872127364959 }, - { x: 0.4775641025641026, y: 0.27394412472640217 }, - { x: 0.4775641025641026, y: 0.27100103671630843 }, - { x: 0.4775641025641026, y: 0.2680579487062147 }, - { x: 0.4775641025641026, y: 0.26511486069612095 }, - { x: 0.4775641025641026, y: 0.2648876956322441 }, - { x: 0.4791666666666667, y: 0.2665640756990374 }, - { x: 0.4795634385114741, y: 0.2665640756990374 }, - { x: 0.48250652652156784, y: 0.2665640756990374 }, - { x: 0.4854496145316616, y: 0.2665640756990374 }, - { x: 0.4883927025417553, y: 0.2665640756990374 }, - { x: 0.4911858974358974, y: 0.2665640756990374 }, - { x: 0.4913300394226815, y: 0.2665229535169987 }, - { x: 0.4941602064411068, y: 0.2657155368445333 }, - { x: 0.4969903734595321, y: 0.26490812017206794 }, - { x: 0.49982054047795743, y: 0.26410070349960263 }, - { x: 0.5026507074963827, y: 0.26329328682713726 }, - { x: 0.505480874514808, y: 0.26248587015467195 }, - { x: 0.5083110415332334, y: 0.2616784534822066 }, - { x: 0.5088141025641025, y: 0.2615349354986576 }, - { x: 0.5112273308803348, y: 0.26135462232609513 }, - { x: 0.5141622376809069, y: 0.2611353300391504 }, - { x: 0.5170971444814789, y: 0.2609160377522057 }, - { x: 0.520032051282051, y: 0.26069674546526095 }, - { x: 0.5200320512820513, y: 0.26069674546526095 }, - ], - name: "rectangle", - }, - { - points: [ - { x: 0.5632183908045977, y: 0.3122951675190481 }, - { x: 0.5632183908045977, y: 0.3056152802421459 }, - { x: 0.5670498084291188, y: 0.2989353929652437 }, - { x: 0.5670498084291188, y: 0.29626343805448285 }, - { x: 0.5696040868454662, y: 0.2949274605991024 }, - { x: 0.5708812260536399, y: 0.28958355077758063 }, - { x: 0.578544061302682, y: 0.2762237762237762 }, - { x: 0.5810983397190294, y: 0.27355182131301536 }, - { x: 0.5836526181353767, y: 0.27355182131301536 }, - { x: 0.5836526181353767, y: 0.2802317085899175 }, - { x: 0.5849297573435505, y: 0.2802317085899175 }, - { x: 0.5862068965517241, y: 0.28691159586681975 }, - { x: 0.5887611749680716, y: 0.2882475733222002 }, - { x: 0.5964240102171137, y: 0.3016073478760046 }, - { x: 0.6002554278416348, y: 0.30427930278676546 }, - { x: 0.6015325670498084, y: 0.3082872351529068 }, - { x: 0.6040868454661558, y: 0.30962321260828723 }, - { x: 0.6040868454661558, y: 0.3122951675190481 }, - { x: 0.6091954022988506, y: 0.3176390773405699 }, - { x: 0.6002554278416348, y: 0.3176390773405699 }, - { x: 0.6002554278416348, y: 0.31630309988518945 }, - { x: 0.5964240102171137, y: 0.31630309988518945 }, - { x: 0.5964240102171137, y: 0.314967122429809 }, - { x: 0.5708812260536399, y: 0.31630309988518945 }, - ], - name: "triangle", - }, - { - points: [ - { x: 0.438058748403576, y: 0.40180565702953763 }, - { x: 0.44061302681992337, y: 0.39378979229725497 }, - { x: 0.45338441890166026, y: 0.37375013046654837 }, - { x: 0.46998722860791825, y: 0.3510385137250809 }, - { x: 0.47126436781609193, y: 0.34703058135893955 }, - { x: 0.4827586206896552, y: 0.33367080680513517 }, - { x: 0.4840357598978289, y: 0.3376787391712765 }, - { x: 0.48531289910600256, y: 0.3376787391712765 }, - { x: 0.48659003831417624, y: 0.3456946039035591 }, - { x: 0.5185185185185185, y: 0.3884458824757332 }, - { x: 0.5197956577266922, y: 0.3884458824757332 }, - { x: 0.5197956577266922, y: 0.3911178373864941 }, - { x: 0.5210727969348659, y: 0.3911178373864941 }, - { x: 0.5261813537675607, y: 0.4031416344849181 }, - ], - name: "triangle", - }, - { - points: [ - { x: 0.42528735632183906, y: 0.5006679887276902 }, - { x: 0.43039591315453385, y: 0.5006679887276902 }, - { x: 0.4342273307790549, y: 0.4966600563615489 }, - { x: 0.4508301404853129, y: 0.48597223671850537 }, - { x: 0.46871008939974457, y: 0.4766203945308423 }, - { x: 0.46871008939974457, y: 0.4752844170754619 }, - { x: 0.4725415070242657, y: 0.4752844170754619 }, - { x: 0.47509578544061304, y: 0.4833002818077445 }, - { x: 0.47509578544061304, y: 0.49799603381692936 }, - { x: 0.4763729246487867, y: 0.4993320112723098 }, - { x: 0.4763729246487867, y: 0.5340674251122012 }, - { x: 0.46998722860791825, y: 0.5300594927460599 }, - { x: 0.46998722860791825, y: 0.5287235152906795 }, - { x: 0.46360153256704983, y: 0.5260515603799186 }, - { x: 0.45977011494252873, y: 0.5207076505583969 }, - { x: 0.454661558109834, y: 0.5193716731030164 }, - { x: 0.454661558109834, y: 0.518035695647636 }, - { x: 0.44572158365261816, y: 0.5126917858261142 }, - { x: 0.44316730523627074, y: 0.5126917858261142 }, - { x: 0.44189016602809705, y: 0.5100198309153533 }, - ], - name: "triangle", - }, - { - points: [ - { x: 0.6513409961685823, y: 0.4111574992172007 }, - { x: 0.6794380587484036, y: 0.4111574992172007 }, - { x: 0.6819923371647509, y: 0.4098215217618203 }, - { x: 0.6909323116219668, y: 0.4098215217618203 }, - { x: 0.6922094508301405, y: 0.40848554430643985 }, - { x: 0.7011494252873564, y: 0.40848554430643985 }, - { x: 0.7011494252873564, y: 0.41382945412796157 }, - { x: 0.6909323116219668, y: 0.4245172737710051 }, - { x: 0.6883780332056194, y: 0.43119716104790734 }, - { x: 0.685823754789272, y: 0.4325331385032878 }, - { x: 0.6807151979565773, y: 0.44188498069095083 }, - { x: 0.6768837803320562, y: 0.4445569356017117 }, - { x: 0.6756066411238825, y: 0.4499008454232335 }, - { x: 0.6730523627075351, y: 0.4499008454232335 }, - { x: 0.6666666666666666, y: 0.4432209581463313 }, - { x: 0.6666666666666666, y: 0.4405490032355704 }, - { x: 0.6628352490421456, y: 0.4378770483248095 }, - { x: 0.6526181353767561, y: 0.4205093414048638 }, - ], - name: "triangle", - }, - { - points: [ - { x: 0.6756066411238825, y: 0.18136937689176494 }, - { x: 0.6743295019157088, y: 0.18804926416866716 }, - { x: 0.6513409961685823, y: 0.2227846780085586 }, - { x: 0.6500638569604087, y: 0.22812858783008036 }, - { x: 0.648786717752235, y: 0.22812858783008036 }, - { x: 0.6551724137931034, y: 0.22812858783008036 }, - { x: 0.6564495530012772, y: 0.22679261037469992 }, - { x: 0.6922094508301405, y: 0.22812858783008036 }, - { x: 0.6922094508301405, y: 0.2227846780085586 }, - { x: 0.6896551724137931, y: 0.21744076818703684 }, - { x: 0.6883780332056194, y: 0.21744076818703684 }, - { x: 0.685823754789272, y: 0.21076088091013465 }, - { x: 0.6832694763729247, y: 0.2094249034547542 }, - { x: 0.6832694763729247, y: 0.20675294854399331 }, - { x: 0.6807151979565773, y: 0.20408099363323243 }, - { x: 0.6807151979565773, y: 0.1933931739901889 }, - { x: 0.6794380587484036, y: 0.1933931739901889 }, - ], - name: "triangle", - }, - { - points: [ - { x: 0.454661558109834, y: 0.18537730925790627 }, - { x: 0.454661558109834, y: 0.19072121907942804 }, - { x: 0.4444444444444444, y: 0.20808892599937376 }, - { x: 0.44061302681992337, y: 0.21877674564241728 }, - { x: 0.43167305236270753, y: 0.23347249765160213 }, - { x: 0.43039591315453385, y: 0.23347249765160213 }, - { x: 0.42911877394636017, y: 0.23881640747312388 }, - { x: 0.43167305236270753, y: 0.23480847510698258 }, - { x: 0.438058748403576, y: 0.23347249765160213 }, - { x: 0.4827586206896552, y: 0.23347249765160213 }, - ], - name: "triangle", - }, - { - points: [ - { x: 0.26309067688378035, y: 0.22812858783008036 }, - { x: 0.2669220945083014, y: 0.2321365201962217 }, - { x: 0.26947637292464877, y: 0.23881640747312388 }, - { x: 0.27330779054916987, y: 0.2628640016699718 }, - { x: 0.27458492975734355, y: 0.2628640016699718 }, - { x: 0.27458492975734355, y: 0.2682079114914936 }, - { x: 0.27586206896551724, y: 0.2682079114914936 }, - { x: 0.2784163473818646, y: 0.27755975367915664 }, - { x: 0.2784163473818646, y: 0.2762237762237762 }, - { x: 0.27586206896551724, y: 0.2762237762237762 }, - { x: 0.27330779054916987, y: 0.27355182131301536 }, - { x: 0.2707535121328225, y: 0.27355182131301536 }, - { x: 0.2707535121328225, y: 0.2722158438576349 }, - { x: 0.26436781609195403, y: 0.26954388894687403 }, - { x: 0.2388250319284802, y: 0.26954388894687403 }, - { x: 0.2388250319284802, y: 0.2682079114914936 }, - { x: 0.23371647509578544, y: 0.26687193403611315 }, - ], - name: "triangle", - }, - { - points: [ - { x: 0.21839080459770116, y: 0.4499008454232335 }, - { x: 0.21839080459770116, y: 0.4352050934140486 }, - { x: 0.2247765006385696, y: 0.41382945412796157 }, - { x: 0.2247765006385696, y: 0.4071495668510594 }, - { x: 0.227330779054917, y: 0.40180565702953763 }, - { x: 0.22860791826309068, y: 0.3924538148418745 }, - { x: 0.23371647509578544, y: 0.3750861079219288 }, - { x: 0.23627075351213284, y: 0.3724141530111679 }, - { x: 0.23627075351213284, y: 0.3657342657342657 }, - { x: 0.23754789272030652, y: 0.3643982882788853 }, - { x: 0.25287356321839083, y: 0.4245172737710051 }, - { x: 0.25798212005108556, y: 0.4378770483248095 }, - { x: 0.25925925925925924, y: 0.44856486796785305 }, - { x: 0.2515964240102171, y: 0.44856486796785305 }, - { x: 0.2515964240102171, y: 0.4472288905124726 }, - { x: 0.24648786717752236, y: 0.44589291305709217 }, - { x: 0.23243933588761176, y: 0.44589291305709217 }, - { x: 0.23243933588761176, y: 0.4472288905124726 }, - { x: 0.227330779054917, y: 0.44856486796785305 }, - ], - name: "triangle", - }, - { - points: [ - { x: 0.23627075351213284, y: 0.5006679887276902 }, - { x: 0.23627075351213284, y: 0.5153637407368751 }, - { x: 0.24010217113665389, y: 0.527387537835299 }, - { x: 0.24010217113665389, y: 0.5340674251122012 }, - { x: 0.24521072796934865, y: 0.5500991545767665 }, - { x: 0.2515964240102171, y: 0.5794906585951362 }, - { x: 0.2541507024265645, y: 0.5834985909612775 }, - { x: 0.2541507024265645, y: 0.5875065233274188 }, - { x: 0.24648786717752236, y: 0.5875065233274188 }, - { x: 0.24648786717752236, y: 0.5861705458720384 }, - { x: 0.24265644955300128, y: 0.5861705458720384 }, - { x: 0.24265644955300128, y: 0.5848345684166579 }, - { x: 0.210727969348659, y: 0.5834985909612775 }, - ], - name: "triangle", - }, - { - points: [ - { x: 0.3486590038314176, y: 0.6329297568103538 }, - { x: 0.3563218390804598, y: 0.6329297568103538 }, - { x: 0.36909323116219667, y: 0.6289218244442125 }, - { x: 0.38058748403575987, y: 0.6235779146226907 }, - { x: 0.384418901660281, y: 0.6235779146226907 }, - { x: 0.388250319284802, y: 0.6209059597119299 }, - { x: 0.4074074074074074, y: 0.6155620498904081 }, - { x: 0.421455938697318, y: 0.6088821626135059 }, - { x: 0.4342273307790549, y: 0.606210207702745 }, - { x: 0.44189016602809705, y: 0.6022022753366036 }, - { x: 0.44572158365261816, y: 0.6022022753366036 }, - { x: 0.45721583652618136, y: 0.596858365515082 }, - { x: 0.4610472541507024, y: 0.596858365515082 }, - { x: 0.4610472541507024, y: 0.5955223880597015 }, - { x: 0.4623243933588761, y: 0.596858365515082 }, - { x: 0.4623243933588761, y: 0.6022022753366036 }, - { x: 0.4661558109833972, y: 0.6075461851581254 }, - { x: 0.4776500638569604, y: 0.6155620498904081 }, - { x: 0.4904214559386973, y: 0.6262498695334516 }, - { x: 0.49169859514687103, y: 0.6289218244442125 }, - { x: 0.4955300127713921, y: 0.6302578018995929 }, - { x: 0.49680715197956576, y: 0.6329297568103538 }, - { x: 0.5006385696040868, y: 0.6342657342657343 }, - { x: 0.5006385696040868, y: 0.6356017117211147 }, - { x: 0.5044699872286079, y: 0.6369376891764952 }, - { x: 0.508301404853129, y: 0.6409456215426365 }, - { x: 0.5172413793103449, y: 0.6449535539087778 }, - ], - name: "triangle", - }, - { - points: [ - { x: 0.6091954022988506, y: 0.4966600563615489 }, - { x: 0.6091954022988506, y: 0.5527711094875274 }, - { x: 0.6104725415070242, y: 0.5527711094875274 }, - { x: 0.6104725415070242, y: 0.5581150193090492 }, - { x: 0.611749680715198, y: 0.5541070869429079 }, - { x: 0.6130268199233716, y: 0.5541070869429079 }, - { x: 0.6143039591315453, y: 0.5474271996660056 }, - { x: 0.6219667943805874, y: 0.539411334933723 }, - { x: 0.6462324393358876, y: 0.5233796054691577 }, - { x: 0.6551724137931034, y: 0.5140277632814946 }, - { x: 0.6577266922094508, y: 0.5140277632814946 }, - { x: 0.6500638569604087, y: 0.5140277632814946 }, - { x: 0.648786717752235, y: 0.5113558083707337 }, - { x: 0.6257982120051085, y: 0.49799603381692936 }, - { x: 0.6155810983397191, y: 0.4966600563615489 }, - ], - name: "triangle", - }, - { - points: [ - { x: 0.7011494252873564, y: 0.5447552447552447 }, - { x: 0.7650063856960408, y: 0.5447552447552447 }, - { x: 0.7662835249042146, y: 0.5434192672998643 }, - { x: 0.776500638569604, y: 0.5434192672998643 }, - { x: 0.7790549169859514, y: 0.5420832898444838 }, - { x: 0.8582375478927203, y: 0.5407473123891035 }, - { x: 0.8582375478927203, y: 0.5434192672998643 }, - { x: 0.855683269476373, y: 0.5447552447552447 }, - { x: 0.8518518518518519, y: 0.5447552447552447 }, - { x: 0.8518518518518519, y: 0.5460912222106252 }, - { x: 0.8454661558109834, y: 0.5474271996660056 }, - { x: 0.8454661558109834, y: 0.5487631771213861 }, - { x: 0.8416347381864623, y: 0.5487631771213861 }, - { x: 0.7969348659003831, y: 0.582162613505897 }, - { x: 0.7943805874840357, y: 0.582162613505897 }, - { x: 0.7816091954022989, y: 0.5888425007827993 }, - { x: 0.7816091954022989, y: 0.5901784782381797 }, - { x: 0.7752234993614304, y: 0.5901784782381797 }, - { x: 0.7739463601532567, y: 0.5915144556935602 }, - ], - name: "triangle", - }, - { - points: [ - { x: 0.665389527458493, y: 0.6783529902932888 }, - { x: 0.6666666666666666, y: 0.6235779146226907 }, - { x: 0.6781609195402298, y: 0.5661308840413318 }, - { x: 0.6781609195402298, y: 0.5554430643982883 }, - { x: 0.6794380587484036, y: 0.5554430643982883 }, - { x: 0.685823754789272, y: 0.5768187036843754 }, - { x: 0.6896551724137931, y: 0.6168980273457886 }, - { x: 0.6973180076628352, y: 0.6556413735518213 }, - { x: 0.7011494252873564, y: 0.6877048324809519 }, - { x: 0.7011494252873564, y: 0.685032877570191 }, - ], - name: "triangle", - }, - { - points: [ - { x: 0.48148148148148145, y: 0.7277841561423651 }, - { x: 0.48148148148148145, y: 0.7211042688654629 }, - { x: 0.48659003831417624, y: 0.7117524266777998 }, - { x: 0.4878671775223499, y: 0.7050725394008976 }, - { x: 0.4929757343550447, y: 0.697056674668615 }, - { x: 0.4929757343550447, y: 0.6930487423024736 }, - { x: 0.49808429118773945, y: 0.6823609226594302 }, - { x: 0.5006385696040868, y: 0.6810249452040497 }, - { x: 0.5070242656449553, y: 0.6983926521239954 }, - { x: 0.5108556832694764, y: 0.7024005844901368 }, - { x: 0.5223499361430396, y: 0.7237762237762237 }, - { x: 0.5236270753512133, y: 0.7237762237762237 }, - { x: 0.5261813537675607, y: 0.7317920885085064 }, - { x: 0.5210727969348659, y: 0.7317920885085064 }, - { x: 0.5185185185185185, y: 0.7291201335977455 }, - { x: 0.508301404853129, y: 0.7277841561423651 }, - { x: 0.508301404853129, y: 0.7264481786869846 }, - { x: 0.4891443167305236, y: 0.7264481786869846 }, - ], - name: "triangle", - }, - { - points: [ - { x: 0.37037037037037035, y: 0.6836969001148105 }, - { x: 0.37037037037037035, y: 0.6877048324809519 }, - { x: 0.36398467432950193, y: 0.6997286295793759 }, - { x: 0.3537675606641124, y: 0.7157603590439411 }, - { x: 0.34610472541507026, y: 0.7237762237762237 }, - { x: 0.34355044699872284, y: 0.730456111053126 }, - { x: 0.3499361430395913, y: 0.7277841561423651 }, - { x: 0.3499361430395913, y: 0.7264481786869846 }, - { x: 0.38058748403575987, y: 0.7264481786869846 }, - { x: 0.3818646232439336, y: 0.7277841561423651 }, - { x: 0.388250319284802, y: 0.7277841561423651 }, - { x: 0.388250319284802, y: 0.7211042688654629 }, - { x: 0.384418901660281, y: 0.7144243815885607 }, - { x: 0.3831417624521073, y: 0.7144243815885607 }, - { x: 0.38058748403575987, y: 0.7077444943116585 }, - { x: 0.3793103448275862, y: 0.7077444943116585 }, - { x: 0.3793103448275862, y: 0.7050725394008976 }, - ], - name: "triangle", - }, - { - points: [ - { x: 0.1698595146871009, y: 0.6396096440872561 }, - { x: 0.15964240102171137, y: 0.6396096440872561 }, - { x: 0.1583652618135377, y: 0.642281598998017 }, - { x: 0.13026819923371646, y: 0.6623212608287236 }, - { x: 0.13793103448275862, y: 0.6623212608287236 }, - { x: 0.14559386973180077, y: 0.6676651706502453 }, - { x: 0.14814814814814814, y: 0.6676651706502453 }, - { x: 0.1558109833971903, y: 0.6743450579271475 }, - { x: 0.1583652618135377, y: 0.6743450579271475 }, - { x: 0.16219667943805874, y: 0.6783529902932888 }, - { x: 0.1685823754789272, y: 0.6810249452040497 }, - { x: 0.1724137931034483, y: 0.685032877570191 }, - { x: 0.17496807151979565, y: 0.685032877570191 }, - { x: 0.17369093231162197, y: 0.673009080471767 }, - { x: 0.1724137931034483, y: 0.673009080471767 }, - { x: 0.1724137931034483, y: 0.6569773510072018 }, - { x: 0.1698595146871009, y: 0.6569773510072018 }, - ], - name: "triangle", - }, - { - points: [ - { x: 0.4591728525980912, y: 0.1977155336776788 }, - { x: 0.4591728525980912, y: 0.20343754210605733 }, - { x: 0.4591728525980912, y: 0.20915955053443586 }, - { x: 0.4591728525980912, y: 0.21488155896281438 }, - { x: 0.4591728525980912, y: 0.2206035673911929 }, - { x: 0.4591728525980912, y: 0.22632557581957144 }, - { x: 0.4591728525980912, y: 0.23204758424794997 }, - { x: 0.4591728525980912, y: 0.2377695926763285 }, - { x: 0.4591728525980912, y: 0.24349160110470702 }, - { x: 0.4591728525980912, y: 0.24921360953308555 }, - { x: 0.4591728525980912, y: 0.2549356179614641 }, - { x: 0.4591728525980912, y: 0.26065762638984263 }, - { x: 0.4591728525980912, y: 0.2663796348182212 }, - { x: 0.4591728525980912, y: 0.27210164324659974 }, - { x: 0.4591728525980912, y: 0.2778236516749783 }, - { x: 0.4591728525980912, y: 0.28354566010335686 }, - { x: 0.4591728525980912, y: 0.2892676685317354 }, - { x: 0.4591728525980912, y: 0.29498967696011397 }, - { x: 0.4591728525980912, y: 0.3007116853884925 }, - { x: 0.4591728525980912, y: 0.3064336938168711 }, - { x: 0.4591728525980912, y: 0.31215570224524963 }, - { x: 0.4591728525980912, y: 0.3178777106736282 }, - { x: 0.4591728525980912, y: 0.32359971910200674 }, - { x: 0.4591728525980912, y: 0.3293217275303853 }, - { x: 0.4591728525980912, y: 0.33504373595876386 }, - { x: 0.4591728525980912, y: 0.3407657443871424 }, - { x: 0.4591728525980912, y: 0.34648775281552097 }, - { x: 0.4591728525980912, y: 0.34858044164037855 }, - { x: 0.4602332979851538, y: 0.34858044164037855 }, - { x: 0.46053809669050594, y: 0.35113116947556605 }, - { x: 0.46121701695441375, y: 0.35681275796204504 }, - { x: 0.46129374337221635, y: 0.3574548479911256 }, - { x: 0.46129374337221635, y: 0.3625301984352238 }, - { x: 0.46129374337221635, y: 0.3682522068636024 }, - { x: 0.46129374337221635, y: 0.36965715672340277 }, - { x: 0.46307453718696423, y: 0.36572450264836776 }, - { x: 0.4654348751474816, y: 0.36051199987459626 }, - { x: 0.467795213107999, y: 0.35529949710082476 }, - { x: 0.47015555106851636, y: 0.35008699432705326 }, - { x: 0.4708377518557794, y: 0.34858044164037855 }, - { x: 0.473149122362902, y: 0.34523263985426356 }, - { x: 0.4764001173564769, y: 0.34052388078140944 }, - { x: 0.4796511123500518, y: 0.3358151217085553 }, - { x: 0.4829021073436267, y: 0.3311063626357012 }, - { x: 0.4861531023372016, y: 0.3263976035628471 }, - { x: 0.4894040973307765, y: 0.32168884448999296 }, - { x: 0.49265509232435145, y: 0.31698008541713885 }, - { x: 0.49590608731792635, y: 0.3122713263442847 }, - { x: 0.4984093319194062, y: 0.3086456130620168 }, - { x: 0.49921665137814286, y: 0.3076062122130033 }, - { x: 0.5027266348516582, y: 0.3030872082884549 }, - { x: 0.5062366183251736, y: 0.2985682043639065 }, - { x: 0.509746601798689, y: 0.2940492004393581 }, - { x: 0.5132565852722044, y: 0.2895301965148097 }, - { x: 0.5167665687457198, y: 0.2850111925902613 }, - { x: 0.5202765522192352, y: 0.28049218866571296 }, - { x: 0.5237865356927507, y: 0.2759731847411646 }, - { x: 0.5259809119830329, y: 0.27314798765902865 }, - { x: 0.5271455475046231, y: 0.271347035693587 }, - { x: 0.5302527450675039, y: 0.2665421731815412 }, - { x: 0.5333599426303847, y: 0.26173731066949546 }, - { x: 0.5364671401932654, y: 0.25693244815744964 }, - { x: 0.5395743377561462, y: 0.2521275856454039 }, - { x: 0.5426815353190269, y: 0.2473227231333581 }, - { x: 0.5457887328819077, y: 0.2425178606213123 }, - { x: 0.5488959304447883, y: 0.23771299810926652 }, - { x: 0.5503711558854719, y: 0.23543176066835372 }, - { x: 0.5524478697779884, y: 0.23325937121986418 }, - { x: 0.5556733828207847, y: 0.22988525669913681 }, - { x: 0.5561028692813909, y: 0.22892252845540373 }, - { x: 0.5584340796976681, y: 0.22369693390076448 }, - { x: 0.5607652901139454, y: 0.21847133934612523 }, - { x: 0.5630965005302226, y: 0.2132457447914861 }, - ], - name: "triangle", - }, - { - points: [ - { x: 0.7900318133616119, y: 0.15999930668700385 }, - { x: 0.7946838425986257, y: 0.15999930668700385 }, - { x: 0.7993358718356395, y: 0.15999930668700385 }, - { x: 0.8039879010726533, y: 0.15999930668700385 }, - { x: 0.8086399303096671, y: 0.15999930668700385 }, - { x: 0.8132919595466809, y: 0.15999930668700385 }, - { x: 0.8179439887836947, y: 0.15999930668700385 }, - { x: 0.8225960180207085, y: 0.15999930668700385 }, - { x: 0.8272480472577223, y: 0.15999930668700385 }, - { x: 0.8319000764947361, y: 0.15999930668700385 }, - { x: 0.8365521057317499, y: 0.15999930668700385 }, - { x: 0.8412041349687637, y: 0.15999930668700385 }, - { x: 0.8458561642057775, y: 0.15999930668700385 }, - { x: 0.8505081934427913, y: 0.15999930668700385 }, - { x: 0.8551602226798051, y: 0.15999930668700385 }, - { x: 0.8598122519168189, y: 0.15999930668700385 }, - { x: 0.8644642811538327, y: 0.15999930668700385 }, - { x: 0.8691163103908465, y: 0.15999930668700385 }, - { x: 0.8737683396278603, y: 0.15999930668700385 }, - { x: 0.8784203688648741, y: 0.15999930668700385 }, - { x: 0.8830723981018879, y: 0.15999930668700385 }, - { x: 0.8877244273389017, y: 0.15999930668700385 }, - { x: 0.8907741251325557, y: 0.15999930668700385 }, - { x: 0.8904360306336263, y: 0.1615655627848924 }, - { x: 0.8894544450194818, y: 0.16611285491363628 }, - { x: 0.8884728594053373, y: 0.17066014704238017 }, - { x: 0.8874912737911929, y: 0.17520743917112405 }, - { x: 0.8865096881770483, y: 0.17975473129986794 }, - { x: 0.8855281025629039, y: 0.18430202342861182 }, - { x: 0.8845465169487594, y: 0.1888493155573557 }, - { x: 0.883564931334615, y: 0.1933966076860996 }, - { x: 0.8833510074231177, y: 0.19438763129614864 }, - { x: 0.882803987503398, y: 0.19798445204150153 }, - { x: 0.882104529809995, y: 0.20258359706608717 }, - { x: 0.881405072116592, y: 0.2071827420906728 }, - { x: 0.8807056144231892, y: 0.21178188711525844 }, - { x: 0.8800061567297862, y: 0.21638103213984408 }, - { x: 0.8793066990363834, y: 0.22098017716442972 }, - { x: 0.8786072413429804, y: 0.22557932218901536 }, - { x: 0.8779077836495776, y: 0.230178467213601 }, - { x: 0.8772083259561747, y: 0.23477761223818663 }, - { x: 0.8765088682627717, y: 0.23937675726277227 }, - { x: 0.8758094105693689, y: 0.2439759022873579 }, - { x: 0.8751099528759659, y: 0.24857504731194355 }, - { x: 0.8744104951825631, y: 0.2531741923365292 }, - { x: 0.8737110374891601, y: 0.25777333736111485 }, - { x: 0.8730115797957573, y: 0.2623724823857005 }, - { x: 0.8723121221023543, y: 0.2669716274102861 }, - { x: 0.8716126644089515, y: 0.27157077243487177 }, - { x: 0.8709132067155485, y: 0.2761699174594574 }, - { x: 0.8702137490221457, y: 0.28076906248404304 }, - { x: 0.8695142913287427, y: 0.2853682075086287 }, - { x: 0.8688148336353398, y: 0.2899673525332143 }, - { x: 0.8685047720042418, y: 0.29200610115436615 }, - { x: 0.8678613639186619, y: 0.2945147432315597 }, - { x: 0.8667056338560808, y: 0.29902092388089874 }, - { x: 0.8655499037934997, y: 0.3035271045302378 }, - { x: 0.8643941737309186, y: 0.30803328517957684 }, - { x: 0.8632384436683376, y: 0.3125394658289159 }, - { x: 0.8620827136057565, y: 0.31704564647825495 }, - { x: 0.8609269835431754, y: 0.321551827127594 }, - { x: 0.8597712534805944, y: 0.32605800777693306 }, - { x: 0.8586155234180133, y: 0.3305641884262721 }, - { x: 0.8574597933554322, y: 0.33507036907561116 }, - { x: 0.8568398727465536, y: 0.33748743370194473 }, - { x: 0.8560222276367154, y: 0.3394831676528042 }, - { x: 0.8542585838125305, y: 0.34378792507726447 }, - { x: 0.8536585365853658, y: 0.3452525392588484 }, - { x: 0.8525980911983033, y: 0.34636184005269177 }, - { x: 0.8515376458112409, y: 0.34525253925884863 }, - { x: 0.8515376458112407, y: 0.3452525392588484 }, - ], - name: "triangle", - }, - { - points: [ - { x: 0.24390243902439024, y: 0.4306687003847887 }, - { x: 0.23779215776891913, y: 0.430721092072297 }, - { x: 0.23168187651344802, y: 0.4307734837598052 }, - { x: 0.2255715952579769, y: 0.43082587544731343 }, - { x: 0.2194613140025058, y: 0.43087826713482164 }, - { x: 0.21335103274703468, y: 0.4309306588223299 }, - { x: 0.20724075149156357, y: 0.4309830505098381 }, - { x: 0.20113047023609246, y: 0.43103544219734635 }, - { x: 0.19502018898062135, y: 0.43108783388485455 }, - { x: 0.18890990772515023, y: 0.4311402255723628 }, - { x: 0.18279962646967912, y: 0.431192617259871 }, - { x: 0.176689345214208, y: 0.43124500894737927 }, - { x: 0.1705790639587369, y: 0.4312974006348875 }, - { x: 0.1644687827032658, y: 0.43134979232239573 }, - { x: 0.15835850144779468, y: 0.43140218400990393 }, - { x: 0.15224822019232356, y: 0.4314545756974122 }, - { x: 0.14613793893685245, y: 0.4315069673849204 }, - { x: 0.14002765768138134, y: 0.43155935907242865 }, - { x: 0.13391737642591023, y: 0.43161175075993685 }, - { x: 0.12780709517043912, y: 0.4316641424474451 }, - { x: 0.121696813914968, y: 0.4317165341349533 }, - { x: 0.1155865326594969, y: 0.43176892582246157 }, - { x: 0.11452810180275716, y: 0.4317780011786321 }, - { x: 0.10950175517476975, y: 0.43228683153449 }, - { x: 0.1034223210743054, y: 0.43290226871893117 }, - { x: 0.09734288697384107, y: 0.4335177059033724 }, - { x: 0.09126345287337673, y: 0.4341331430878136 }, - { x: 0.08518401877291239, y: 0.4347485802722548 }, - { x: 0.0816542948038176, y: 0.4351059035601622 }, - { x: 0.0816542948038176, y: 0.4362152043540056 }, - { x: 0.08310773561567547, y: 0.4362152043540056 }, - { x: 0.08589607635206786, y: 0.4362152043540056 }, - { x: 0.08827908890679795, y: 0.4339004619716089 }, - { x: 0.0926621980031301, y: 0.4296429231802963 }, - { x: 0.09704530709946224, y: 0.42538538438898366 }, - { x: 0.1007423117709438, y: 0.4217942940340417 }, - { x: 0.10131302523822786, y: 0.4210267141109059 }, - { x: 0.10495897081042967, y: 0.41612310699018107 }, - { x: 0.10816542948038176, y: 0.41181058688945127 }, - { x: 0.10869246118754315, y: 0.41129602864969383 }, - { x: 0.11306467093781976, y: 0.40702729771304913 }, - { x: 0.11743688068809635, y: 0.4027585667764044 }, - { x: 0.12180909043837294, y: 0.39848983583975967 }, - { x: 0.12407211028632026, y: 0.3962803757756439 }, - { x: 0.1258424393864069, y: 0.3939234257447946 }, - { x: 0.12951221333329013, y: 0.3890376258012174 }, - { x: 0.13318198728017336, y: 0.3841518258576403 }, - { x: 0.1357370095440085, y: 0.3807501646618366 }, - { x: 0.13725950843091317, y: 0.37968840372976426 }, - { x: 0.13891834570519618, y: 0.3785315630741498 }, - { x: 0.14068134442232255, y: 0.3748431206511592 }, - { x: 0.14103923647932132, y: 0.3740943598987763 }, - { x: 0.14457794371663737, y: 0.3701748728429738 }, - { x: 0.1486727920126013, y: 0.36563940160583397 }, - { x: 0.15276764030856524, y: 0.36110393036869415 }, - { x: 0.15686248860452917, y: 0.3565684591315543 }, - { x: 0.15906680805938495, y: 0.35412694560959546 }, - { x: 0.16138080963593865, y: 0.35251320618957294 }, - { x: 0.16542948038176034, y: 0.3496897424342219 }, - { x: 0.16614299535089544, y: 0.34875675860104083 }, - { x: 0.16967126193001061, y: 0.34414323846500505 }, - { x: 0.16995687107329605, y: 0.3440436493510847 }, - { x: 0.1728525980911983, y: 0.34303393767116164 }, - { x: 0.17503050021822517, y: 0.340907580813274 }, - { x: 0.17940270996850177, y: 0.3366388498766293 }, - { x: 0.18377491971877838, y: 0.3323701189399846 }, - { x: 0.18814712946905499, y: 0.3281013880033399 }, - { x: 0.1925193392193316, y: 0.3238326570666952 }, - { x: 0.1968915489696082, y: 0.31956392613005047 }, - { x: 0.20126375871988478, y: 0.3152951951934057 }, - { x: 0.2046659597030753, y: 0.311973515443547 }, - { x: 0.20574488271501684, y: 0.311152693847267 }, - { x: 0.2106080116605205, y: 0.3074529291561973 }, - { x: 0.2154711406060242, y: 0.3037531644651277 }, - { x: 0.2163308589607635, y: 0.30309910909279997 }, - { x: 0.2196305093007199, y: 0.2993022749844221 }, - { x: 0.22363874795485683, y: 0.2946900840949029 }, - { x: 0.22693531283138918, y: 0.29089680036052273 }, - { x: 0.22730143152064247, y: 0.28987550432124354 }, - { x: 0.22936345927612115, y: 0.2841234330903288 }, - { x: 0.2301166489925769, y: 0.2820223940097757 }, - { x: 0.23329798515376457, y: 0.27980379242208897 }, - ], - name: "triangle", - }, - { - points: [ - { x: 0.5427841634738186, y: 0.18404133180252583 }, - { x: 0.5402298850574713, y: 0.18404133180252583 }, - { x: 0.5402298850574713, y: 0.18537730925790627 }, - { x: 0.5338441890166028, y: 0.18804926416866716 }, - { x: 0.524904214559387, y: 0.19472915144556935 }, - { x: 0.5172413793103449, y: 0.20408099363323243 }, - { x: 0.5172413793103449, y: 0.21744076818703684 }, - { x: 0.5351213282247765, y: 0.23614445256236302 }, - { x: 0.5504469987228607, y: 0.23614445256236302 }, - { x: 0.5593869731800766, y: 0.22679261037469992 }, - { x: 0.5632183908045977, y: 0.2254566329193195 }, - { x: 0.5734355044699873, y: 0.21343283582089553 }, - { x: 0.5734355044699873, y: 0.20541697108861287 }, - { x: 0.5696040868454662, y: 0.20140903872247157 }, - { x: 0.5696040868454662, y: 0.19740110635633024 }, - { x: 0.5644955300127714, y: 0.18804926416866716 }, - { x: 0.5632183908045977, y: 0.18136937689176494 }, - { x: 0.558109833971903, y: 0.17468948961486275 }, - { x: 0.5555555555555556, y: 0.17468948961486275 }, - { x: 0.5517241379310345, y: 0.18003339943638452 }, - ], - name: "circle", - }, - { - points: [ - { x: 0.454661558109834, y: 0.2748877987683958 }, - { x: 0.4495530012771392, y: 0.2748877987683958 }, - { x: 0.4495530012771392, y: 0.2762237762237762 }, - { x: 0.44316730523627074, y: 0.27755975367915664 }, - { x: 0.4355044699872286, y: 0.28691159586681975 }, - { x: 0.4367816091954023, y: 0.3109591900636677 }, - { x: 0.44061302681992337, y: 0.31897505479595034 }, - { x: 0.44572158365261816, y: 0.3203110322513308 }, - { x: 0.44572158365261816, y: 0.3216470097067112 }, - { x: 0.46360153256704983, y: 0.3203110322513308 }, - { x: 0.46360153256704983, y: 0.31897505479595034 }, - { x: 0.47126436781609193, y: 0.31630309988518945 }, - { x: 0.47509578544061304, y: 0.3109591900636677 }, - { x: 0.4776500638569604, y: 0.2882475733222002 }, - { x: 0.47509578544061304, y: 0.28691159586681975 }, - { x: 0.47381864623243936, y: 0.28156768604529797 }, - { x: 0.46998722860791825, y: 0.27755975367915664 }, - { x: 0.45849297573435505, y: 0.27755975367915664 }, - ], - name: "circle", - }, - { - points: [ - { x: 0.49936143039591313, y: 0.4098215217618203 }, - { x: 0.4955300127713921, y: 0.4098215217618203 }, - { x: 0.48148148148148145, y: 0.41650140903872246 }, - { x: 0.47126436781609193, y: 0.42585325122638557 }, - { x: 0.4674329501915709, y: 0.4338691159586682 }, - { x: 0.4661558109833972, y: 0.44589291305709217 }, - { x: 0.4674329501915709, y: 0.44589291305709217 }, - { x: 0.4674329501915709, y: 0.44856486796785305 }, - { x: 0.46998722860791825, y: 0.44856486796785305 }, - { x: 0.46998722860791825, y: 0.45123682287861394 }, - { x: 0.47381864623243936, y: 0.4552447552447552 }, - { x: 0.4840357598978289, y: 0.46192464252165744 }, - { x: 0.4955300127713921, y: 0.46593257488779877 }, - { x: 0.5057471264367817, y: 0.46593257488779877 }, - { x: 0.51213282247765, y: 0.45925268761089655 }, - { x: 0.5172413793103449, y: 0.4499008454232335 }, - { x: 0.5210727969348659, y: 0.43921302578018995 }, - { x: 0.5223499361430396, y: 0.43921302578018995 }, - { x: 0.5223499361430396, y: 0.43119716104790734 }, - { x: 0.5159642401021711, y: 0.4231812963156247 }, - { x: 0.5095785440613027, y: 0.40848554430643985 }, - { x: 0.5057471264367817, y: 0.40581358939567896 }, - { x: 0.5057471264367817, y: 0.4031416344849181 }, - { x: 0.5019157088122606, y: 0.4031416344849181 }, - ], - name: "circle", - }, - { - points: [ - { x: 0.2413793103448276, y: 0.3643982882788853 }, - { x: 0.23754789272030652, y: 0.3643982882788853 }, - { x: 0.23754789272030652, y: 0.3657342657342657 }, - { x: 0.22860791826309068, y: 0.3684062206450266 }, - { x: 0.22860791826309068, y: 0.36974219810040704 }, - { x: 0.2247765006385696, y: 0.36974219810040704 }, - { x: 0.2247765006385696, y: 0.3710781755557875 }, - { x: 0.21966794380587484, y: 0.37375013046654837 }, - { x: 0.22094508301404853, y: 0.3951257697526354 }, - { x: 0.22349936143039592, y: 0.3977977246633963 }, - { x: 0.22349936143039592, y: 0.4004696795741572 }, - { x: 0.23116219667943805, y: 0.40848554430643985 }, - { x: 0.24010217113665389, y: 0.41382945412796157 }, - { x: 0.24010217113665389, y: 0.415165431583342 }, - { x: 0.2541507024265645, y: 0.415165431583342 }, - { x: 0.25925925925925924, y: 0.4098215217618203 }, - { x: 0.26181353767560667, y: 0.4098215217618203 }, - { x: 0.26309067688378035, y: 0.40581358939567896 }, - { x: 0.26436781609195403, y: 0.40581358939567896 }, - { x: 0.26436781609195403, y: 0.40180565702953763 }, - { x: 0.2656449553001277, y: 0.40180565702953763 }, - { x: 0.2656449553001277, y: 0.3804300177434506 }, - { x: 0.24904214559386972, y: 0.36172633336812443 }, - { x: 0.2388250319284802, y: 0.360390355912744 }, - ], - name: "circle", - }, - { - points: [ - { x: 0.2796934865900383, y: 0.5220436280137772 }, - { x: 0.2822477650063857, y: 0.5153637407368751 }, - { x: 0.28735632183908044, y: 0.5126917858261142 }, - { x: 0.2886334610472541, y: 0.5086838534599729 }, - { x: 0.2886334610472541, y: 0.5033399436384511 }, - { x: 0.2835249042145594, y: 0.4993320112723098 }, - { x: 0.2681992337164751, y: 0.4993320112723098 }, - { x: 0.26181353767560667, y: 0.506011898549212 }, - { x: 0.26181353767560667, y: 0.518035695647636 }, - { x: 0.26309067688378035, y: 0.518035695647636 }, - { x: 0.26309067688378035, y: 0.5207076505583969 }, - { x: 0.2656449553001277, y: 0.5233796054691577 }, - { x: 0.27458492975734355, y: 0.5233796054691577 }, - { x: 0.27458492975734355, y: 0.5207076505583969 }, - ], - name: "circle", - }, - { - points: [ - { x: 0.4904214559386973, y: 0.5126917858261142 }, - { x: 0.4827586206896552, y: 0.5126917858261142 }, - { x: 0.47381864623243936, y: 0.5220436280137772 }, - { x: 0.4674329501915709, y: 0.5300594927460599 }, - { x: 0.45338441890166026, y: 0.5567790418536687 }, - { x: 0.4482758620689655, y: 0.5741467487736145 }, - { x: 0.4482758620689655, y: 0.596858365515082 }, - { x: 0.4495530012771392, y: 0.5995303204258429 }, - { x: 0.4648786717752235, y: 0.618234004801169 }, - { x: 0.47381864623243936, y: 0.6262498695334516 }, - { x: 0.4840357598978289, y: 0.6329297568103538 }, - { x: 0.49936143039591313, y: 0.6396096440872561 }, - { x: 0.51213282247765, y: 0.642281598998017 }, - { x: 0.5351213282247765, y: 0.642281598998017 }, - { x: 0.5542784163473818, y: 0.6382736666318756 }, - { x: 0.565772669220945, y: 0.6329297568103538 }, - { x: 0.5772669220945083, y: 0.6155620498904081 }, - { x: 0.5823754789272031, y: 0.5928504331489406 }, - { x: 0.5823754789272031, y: 0.5634589291305709 }, - { x: 0.5747126436781609, y: 0.539411334933723 }, - { x: 0.561941251596424, y: 0.5220436280137772 }, - { x: 0.5504469987228607, y: 0.5113558083707337 }, - { x: 0.5478927203065134, y: 0.5113558083707337 }, - { x: 0.5478927203065134, y: 0.5100198309153533 }, - { x: 0.5146871008939975, y: 0.5086838534599729 }, - { x: 0.5134099616858238, y: 0.5073478760045924 }, - { x: 0.4929757343550447, y: 0.5073478760045924 }, - { x: 0.49169859514687103, y: 0.5100198309153533 }, - ], - name: "circle", - }, - { - points: [ - { x: 0.7203065134099617, y: 0.4672685523431792 }, - { x: 0.7100893997445722, y: 0.4672685523431792 }, - { x: 0.7037037037037037, y: 0.4699405072539401 }, - { x: 0.6883780332056194, y: 0.4779563719862227 }, - { x: 0.6768837803320562, y: 0.4873082141738858 }, - { x: 0.6641123882503193, y: 0.5033399436384511 }, - { x: 0.6564495530012772, y: 0.5260515603799186 }, - { x: 0.6551724137931034, y: 0.5581150193090492 }, - { x: 0.6590038314176245, y: 0.5688028389520927 }, - { x: 0.669220945083014, y: 0.5875065233274188 }, - { x: 0.6794380587484036, y: 0.5981943429704624 }, - { x: 0.6819923371647509, y: 0.5981943429704624 }, - { x: 0.6909323116219668, y: 0.6035382527919841 }, - { x: 0.7062579821200511, y: 0.6075461851581254 }, - { x: 0.735632183908046, y: 0.6075461851581254 }, - { x: 0.7369093231162197, y: 0.606210207702745 }, - { x: 0.7471264367816092, y: 0.6048742302473645 }, - { x: 0.7522349936143039, y: 0.6022022753366036 }, - { x: 0.7624521072796935, y: 0.6008662978812233 }, - { x: 0.7739463601532567, y: 0.5941864106043211 }, - { x: 0.7790549169859514, y: 0.5928504331489406 }, - { x: 0.7828863346104725, y: 0.5888425007827993 }, - { x: 0.7854406130268199, y: 0.5888425007827993 }, - { x: 0.7969348659003831, y: 0.5768187036843754 }, - { x: 0.8058748403575989, y: 0.56078697421981 }, - { x: 0.8058748403575989, y: 0.5567790418536687 }, - { x: 0.8071519795657727, y: 0.5567790418536687 }, - { x: 0.8071519795657727, y: 0.551435132032147 }, - { x: 0.80970625798212, y: 0.5434192672998643 }, - { x: 0.80970625798212, y: 0.5207076505583969 }, - { x: 0.8084291187739464, y: 0.5207076505583969 }, - { x: 0.8071519795657727, y: 0.5153637407368751 }, - { x: 0.7994891443167306, y: 0.5033399436384511 }, - { x: 0.789272030651341, y: 0.4899801690846467 }, - { x: 0.7803320561941252, y: 0.48597223671850537 }, - ], - name: "circle", - }, - { - points: [ - { x: 0.6934865900383141, y: 0.3309988518943743 }, - { x: 0.669220945083014, y: 0.3309988518943743 }, - { x: 0.665389527458493, y: 0.3283268969836134 }, - { x: 0.6590038314176245, y: 0.32699091952823295 }, - { x: 0.6513409961685823, y: 0.3216470097067112 }, - { x: 0.6424010217113666, y: 0.30962321260828723 }, - { x: 0.6398467432950191, y: 0.302943325331385 }, - { x: 0.6385696040868455, y: 0.2829036635006784 }, - { x: 0.6462324393358876, y: 0.2628640016699718 }, - { x: 0.648786717752235, y: 0.26152802421459137 }, - { x: 0.6500638569604087, y: 0.25752009184845004 }, - { x: 0.6590038314176245, y: 0.24816824966078696 }, - { x: 0.6730523627075351, y: 0.24015238492850433 }, - { x: 0.6832694763729247, y: 0.23748043001774344 }, - { x: 0.6896551724137931, y: 0.23614445256236302 }, - { x: 0.7126436781609196, y: 0.23614445256236302 }, - { x: 0.7343550446998723, y: 0.24683227220540654 }, - { x: 0.7471264367816092, y: 0.2655359565807327 }, - { x: 0.7509578544061303, y: 0.2762237762237762 }, - { x: 0.7509578544061303, y: 0.314967122429809 }, - { x: 0.7496807151979565, y: 0.3176390773405699 }, - { x: 0.7369093231162197, y: 0.33367080680513517 }, - { x: 0.7343550446998723, y: 0.33367080680513517 }, - { x: 0.7318007662835249, y: 0.33634276171589605 }, - { x: 0.7254150702426565, y: 0.33634276171589605 }, - { x: 0.7254150702426565, y: 0.3376787391712765 }, - { x: 0.7164750957854407, y: 0.3376787391712765 }, - ], - name: "circle", - }, - { - points: [ - { x: 0.1583652618135377, y: 0.6262498695334516 }, - { x: 0.1545338441890166, y: 0.6222419371673104 }, - { x: 0.15070242656449553, y: 0.6115541175242668 }, - { x: 0.14942528735632185, y: 0.6115541175242668 }, - { x: 0.14687100893997446, y: 0.5888425007827993 }, - { x: 0.14814814814814814, y: 0.5888425007827993 }, - { x: 0.1532567049808429, y: 0.5794906585951362 }, - { x: 0.16219667943805874, y: 0.5714747938628536 }, - { x: 0.17879948914431673, y: 0.5634589291305709 }, - { x: 0.20434227330779056, y: 0.5634589291305709 }, - { x: 0.21455938697318008, y: 0.5674668614967122 }, - { x: 0.2247765006385696, y: 0.5741467487736145 }, - { x: 0.23371647509578544, y: 0.5888425007827993 }, - { x: 0.23499361430395913, y: 0.5888425007827993 }, - { x: 0.23499361430395913, y: 0.5915144556935602 }, - { x: 0.23627075351213284, y: 0.5915144556935602 }, - { x: 0.23627075351213284, y: 0.6168980273457886 }, - { x: 0.23243933588761176, y: 0.627585846988832 }, - { x: 0.22349936143039592, y: 0.6436175764533973 }, - { x: 0.21711366538952745, y: 0.6502974637302995 }, - { x: 0.2120051085568327, y: 0.65163344118568 }, - { x: 0.2120051085568327, y: 0.6529694186410604 }, - { x: 0.20178799489144317, y: 0.6556413735518213 }, - { x: 0.1826309067688378, y: 0.6556413735518213 }, - { x: 0.17752234993614305, y: 0.6529694186410604 }, - { x: 0.1698595146871009, y: 0.6449535539087778 }, - { x: 0.1698595146871009, y: 0.6396096440872561 }, - ], - name: "circle", - }, - { - points: [ - { x: 0.11749680715197956, y: 0.1399540757749713 }, - { x: 0.10600255427841634, y: 0.1399540757749713 }, - { x: 0.10217113665389528, y: 0.14262603068573218 }, - { x: 0.09578544061302682, y: 0.14396200814111262 }, - { x: 0.09195402298850575, y: 0.14796994050725393 }, - { x: 0.09195402298850575, y: 0.15064189541801482 }, - { x: 0.08939974457215837, y: 0.15197787287339526 }, - { x: 0.08812260536398467, y: 0.16533764742719967 }, - { x: 0.09450830140485313, y: 0.16800960233796056 }, - { x: 0.09450830140485313, y: 0.169345579793341 }, - { x: 0.10089399744572158, y: 0.1733535121594823 }, - { x: 0.10472541507024266, y: 0.1733535121594823 }, - { x: 0.1123882503192848, y: 0.17736144452562363 }, - { x: 0.12005108556832694, y: 0.17736144452562363 }, - { x: 0.12260536398467432, y: 0.17068155724872142 }, - { x: 0.12388250319284802, y: 0.17068155724872142 }, - { x: 0.12388250319284802, y: 0.16800960233796056 }, - { x: 0.1251596424010217, y: 0.16800960233796056 }, - { x: 0.12899106002554278, y: 0.16132971506105834 }, - { x: 0.12899106002554278, y: 0.15064189541801482 }, - { x: 0.1251596424010217, y: 0.1466339630518735 }, - { x: 0.12005108556832694, y: 0.14529798559649307 }, - ], - name: "circle", - }, - { - points: [ - { x: 0.3090676883780332, y: 0.2227846780085586 }, - { x: 0.3090676883780332, y: 0.21744076818703684 }, - { x: 0.30779054916985954, y: 0.21744076818703684 }, - { x: 0.30779054916985954, y: 0.21476881327627595 }, - { x: 0.3052362707535121, y: 0.2120968583655151 }, - { x: 0.3052362707535121, y: 0.1933931739901889 }, - { x: 0.30779054916985954, y: 0.18003339943638452 }, - { x: 0.31928480204342274, y: 0.16800960233796056 }, - { x: 0.32950191570881227, y: 0.16800960233796056 }, - { x: 0.3333333333333333, y: 0.17468948961486275 }, - { x: 0.3333333333333333, y: 0.17736144452562363 }, - { x: 0.334610472541507, y: 0.17736144452562363 }, - { x: 0.334610472541507, y: 0.18003339943638452 }, - { x: 0.3371647509578544, y: 0.18136937689176494 }, - { x: 0.3397190293742018, y: 0.1867132867132867 }, - { x: 0.34099616858237547, y: 0.1960651289009498 }, - { x: 0.34227330779054915, y: 0.1960651289009498 }, - { x: 0.34227330779054915, y: 0.2254566329193195 }, - { x: 0.334610472541507, y: 0.22679261037469992 }, - { x: 0.31928480204342274, y: 0.23347249765160213 }, - { x: 0.3167305236270754, y: 0.23347249765160213 }, - { x: 0.3167305236270754, y: 0.2294645652854608 }, - ], - name: "circle", - }, - { - points: [ - { x: 0.07790549169859515, y: 0.24683227220540654 }, - { x: 0.08045977011494253, y: 0.24683227220540654 }, - { x: 0.08045977011494253, y: 0.2454962947500261 }, - { x: 0.08301404853128991, y: 0.2454962947500261 }, - { x: 0.08684546615581099, y: 0.24148836238388477 }, - { x: 0.09195402298850575, y: 0.24015238492850433 }, - { x: 0.09195402298850575, y: 0.23881640747312388 }, - { x: 0.09578544061302682, y: 0.23881640747312388 }, - { x: 0.09578544061302682, y: 0.23748043001774344 }, - { x: 0.12132822477650064, y: 0.23614445256236302 }, - { x: 0.13409961685823754, y: 0.2508402045715479 }, - { x: 0.13409961685823754, y: 0.25484813693768915 }, - { x: 0.13537675606641125, y: 0.25484813693768915 }, - { x: 0.13537675606641125, y: 0.2708798664022545 }, - { x: 0.13026819923371646, y: 0.2802317085899175 }, - { x: 0.13026819923371646, y: 0.2829036635006784 }, - { x: 0.1277139208173691, y: 0.28423964095605886 }, - { x: 0.12388250319284802, y: 0.2909195282329611 }, - { x: 0.12132822477650064, y: 0.2909195282329611 }, - { x: 0.12132822477650064, y: 0.2922555056883415 }, - { x: 0.11494252873563218, y: 0.2949274605991024 }, - { x: 0.1111111111111111, y: 0.2949274605991024 }, - { x: 0.1111111111111111, y: 0.29626343805448285 }, - { x: 0.08939974457215837, y: 0.29626343805448285 }, - { x: 0.08045977011494253, y: 0.28691159586681975 }, - { x: 0.07790549169859515, y: 0.2762237762237762 }, - { x: 0.07662835249042145, y: 0.2762237762237762 }, - { x: 0.07662835249042145, y: 0.2655359565807327 }, - ], - name: "circle", - }, - { - points: [ - { x: 0.3524904214559387, y: 0.7211042688654629 }, - { x: 0.3524904214559387, y: 0.7157603590439411 }, - { x: 0.3486590038314176, y: 0.7117524266777998 }, - { x: 0.3486590038314176, y: 0.7090804717670389 }, - { x: 0.34738186462324394, y: 0.7090804717670389 }, - { x: 0.34355044699872284, y: 0.6917127648470932 }, - { x: 0.3397190293742018, y: 0.6823609226594302 }, - { x: 0.3397190293742018, y: 0.6556413735518213 }, - { x: 0.3486590038314176, y: 0.6396096440872561 }, - { x: 0.3537675606641124, y: 0.6342657342657343 }, - { x: 0.3614303959131545, y: 0.6289218244442125 }, - { x: 0.3716475095785441, y: 0.627585846988832 }, - { x: 0.3831417624521073, y: 0.6396096440872561 }, - { x: 0.388250319284802, y: 0.6489614862749191 }, - { x: 0.3895274584929757, y: 0.6489614862749191 }, - { x: 0.39719029374201786, y: 0.6743450579271475 }, - { x: 0.39719029374201786, y: 0.697056674668615 }, - { x: 0.3946360153256705, y: 0.7037365619455171 }, - { x: 0.38697318007662834, y: 0.7117524266777998 }, - { x: 0.3767560664112388, y: 0.7277841561423651 }, - { x: 0.3716475095785441, y: 0.7277841561423651 }, - { x: 0.3716475095785441, y: 0.7264481786869846 }, - ], - name: "circle", - }, - { - points: [ - { x: 0.6283524904214559, y: 0.6356017117211147 }, - { x: 0.6104725415070242, y: 0.6356017117211147 }, - { x: 0.6040868454661558, y: 0.6436175764533973 }, - { x: 0.6015325670498084, y: 0.65163344118568 }, - { x: 0.6015325670498084, y: 0.6569773510072018 }, - { x: 0.598978288633461, y: 0.6623212608287236 }, - { x: 0.598978288633461, y: 0.6836969001148105 }, - { x: 0.6040868454661558, y: 0.6863688550255714 }, - { x: 0.6104725415070242, y: 0.6930487423024736 }, - { x: 0.6232439335887612, y: 0.7010646070347563 }, - { x: 0.6309067688378033, y: 0.7037365619455171 }, - { x: 0.6424010217113666, y: 0.7037365619455171 }, - { x: 0.644955300127714, y: 0.7024005844901368 }, - { x: 0.6526181353767561, y: 0.6943847197578541 }, - { x: 0.6564495530012772, y: 0.6836969001148105 }, - { x: 0.6590038314176245, y: 0.6810249452040497 }, - { x: 0.6602809706257982, y: 0.6569773510072018 }, - { x: 0.6590038314176245, y: 0.6569773510072018 }, - { x: 0.6577266922094508, y: 0.65163344118568 }, - { x: 0.6538952745849298, y: 0.6476255088195386 }, - { x: 0.6436781609195402, y: 0.6449535539087778 }, - ], - name: "circle", - }, - { - points: [ - { x: 0.6730523627075351, y: 0.1372821208642104 }, - { x: 0.6666666666666666, y: 0.13461016595344955 }, - { x: 0.6666666666666666, y: 0.1332741884980691 }, - { x: 0.6590038314176245, y: 0.13193821104268866 }, - { x: 0.6551724137931034, y: 0.12793027867654733 }, - { x: 0.6551724137931034, y: 0.12525832376578644 }, - { x: 0.6500638569604087, y: 0.12125039139964514 }, - { x: 0.6500638569604087, y: 0.11323452666736249 }, - { x: 0.6513409961685823, y: 0.11323452666736249 }, - { x: 0.6538952745849298, y: 0.10789061684584073 }, - { x: 0.6551724137931034, y: 0.10121072956893852 }, - { x: 0.6577266922094508, y: 0.0972027972027972 }, - { x: 0.6717752234993615, y: 0.09586681974741676 }, - { x: 0.6730523627075351, y: 0.09853877465817765 }, - { x: 0.6807151979565773, y: 0.10121072956893852 }, - { x: 0.6845466155810983, y: 0.10521866193507985 }, - { x: 0.6883780332056194, y: 0.11991441394426469 }, - { x: 0.6883780332056194, y: 0.1332741884980691 }, - { x: 0.6832694763729247, y: 0.13461016595344955 }, - { x: 0.6832694763729247, y: 0.13594614340883 }, - { x: 0.6781609195402298, y: 0.13594614340883 }, - ], - name: "circle", - }, - { - points: [ - { x: 0.1532567049808429, y: 0.7691994572591587 }, - { x: 0.1417624521072797, y: 0.7691994572591587 }, - { x: 0.13409961685823754, y: 0.7611835925268761 }, - { x: 0.13154533844189017, y: 0.7558396827053544 }, - { x: 0.13026819923371646, y: 0.7558396827053544 }, - { x: 0.12899106002554278, y: 0.7504957728838326 }, - { x: 0.1277139208173691, y: 0.7504957728838326 }, - { x: 0.12643678160919541, y: 0.7358000208746477 }, - { x: 0.13793103448275862, y: 0.7224402463208434 }, - { x: 0.1558109833971903, y: 0.7224402463208434 }, - { x: 0.16219667943805874, y: 0.7291201335977455 }, - { x: 0.16347381864623245, y: 0.7344640434192673 }, - { x: 0.16602809706257982, y: 0.7358000208746477 }, - { x: 0.1673052362707535, y: 0.7398079532407891 }, - { x: 0.1673052362707535, y: 0.763855547437637 }, - { x: 0.16475095785440613, y: 0.763855547437637 }, - { x: 0.15964240102171137, y: 0.7705354347145392 }, - { x: 0.15708812260536398, y: 0.7705354347145392 }, - ], - name: "circle", - }, - { - points: [ - { x: 0.8467432950191571, y: 0.11323452666736249 }, - { x: 0.8263090676883781, y: 0.11323452666736249 }, - { x: 0.8122605363984674, y: 0.1172424590335038 }, - { x: 0.8033205619412516, y: 0.12125039139964514 }, - { x: 0.7918263090676884, y: 0.13060223358730821 }, - { x: 0.7777777777777778, y: 0.15197787287339526 }, - { x: 0.7713920817369093, y: 0.18136937689176494 }, - { x: 0.7713920817369093, y: 0.20541697108861287 }, - { x: 0.776500638569604, y: 0.22144870055317817 }, - { x: 0.7854406130268199, y: 0.23881640747312388 }, - { x: 0.7969348659003831, y: 0.2521761820269283 }, - { x: 0.8058748403575989, y: 0.25752009184845004 }, - { x: 0.8250319284802043, y: 0.2655359565807327 }, - { x: 0.8301404853128991, y: 0.2655359565807327 }, - { x: 0.8378033205619413, y: 0.2682079114914936 }, - { x: 0.8544061302681992, y: 0.2682079114914936 }, - { x: 0.879948914431673, y: 0.25351215948230876 }, - { x: 0.8863346104725415, y: 0.2454962947500261 }, - { x: 0.8876117496807152, y: 0.24015238492850433 }, - { x: 0.9029374201787995, y: 0.2161047907316564 }, - { x: 0.9042145593869731, y: 0.21076088091013465 }, - { x: 0.9054916985951469, y: 0.21076088091013465 }, - { x: 0.9067688378033205, y: 0.16800960233796056 }, - { x: 0.9029374201787995, y: 0.15732178269491703 }, - { x: 0.9016602809706258, y: 0.15732178269491703 }, - { x: 0.9003831417624522, y: 0.15197787287339526 }, - { x: 0.8978288633461047, y: 0.14930591796263437 }, - { x: 0.8978288633461047, y: 0.14529798559649307 }, - { x: 0.8939974457215837, y: 0.1372821208642104 }, - { x: 0.8812260536398467, y: 0.12392234631040601 }, - { x: 0.8697318007662835, y: 0.11991441394426469 }, - { x: 0.8582375478927203, y: 0.11323452666736249 }, - ], - name: "circle", - }, -]; diff --git a/src/helpers/shared.js b/src/helpers/shared.js index aab9b6d..182272a 100644 --- a/src/helpers/shared.js +++ b/src/helpers/shared.js @@ -28,9 +28,10 @@ export function roundTo(x, to) { return Math.round(x / to) * to; } -export function snapPositionToGrid(position, gridSize) { - return { - x: roundTo(position.x, gridSize.x), - y: roundTo(position.y, gridSize.y), - }; +export function toRadians(angle) { + return angle * (Math.PI / 180); +} + +export function toDegrees(angle) { + return angle * (180 / Math.PI); } diff --git a/src/helpers/useDataSource.js b/src/helpers/useDataSource.js new file mode 100644 index 0000000..9eed094 --- /dev/null +++ b/src/helpers/useDataSource.js @@ -0,0 +1,30 @@ +import { useEffect, useState } from "react"; + +// Helper function to load either file or default data +// into a URL and ensure that it is revoked if needed +function useDataSource(data, defaultSources) { + const [dataSource, setDataSource] = useState(null); + useEffect(() => { + if (!data) { + setDataSource(null); + return; + } + let url = null; + if (data.type === "file") { + url = URL.createObjectURL(data.file); + } else if (data.type === "default") { + url = defaultSources[data.key]; + } + setDataSource(url); + + return () => { + if (data.type === "file" && url) { + URL.revokeObjectURL(url); + } + }; + }, [data, defaultSources]); + + return dataSource; +} + +export default useDataSource; diff --git a/src/helpers/useDebounce.js b/src/helpers/useDebounce.js new file mode 100644 index 0000000..057068c --- /dev/null +++ b/src/helpers/useDebounce.js @@ -0,0 +1,18 @@ +import { useEffect, useState } from "react"; + +function useDebounce(value, delay) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timeout = setTimeout(() => { + setDebouncedValue(value); + }, delay); + return () => { + clearTimeout(timeout); + }; + }, [value, delay]); + + return debouncedValue; +} + +export default useDebounce; diff --git a/src/helpers/useNickname.js b/src/helpers/useNickname.js deleted file mode 100644 index 04475da..0000000 --- a/src/helpers/useNickname.js +++ /dev/null @@ -1,16 +0,0 @@ -import { useEffect, useState } from "react"; -import { getRandomMonster } from "./monsters"; - -function useNickname() { - const [nickname, setNickname] = useState( - localStorage.getItem("nickname") || getRandomMonster() - ); - - useEffect(() => { - localStorage.setItem("nickname", nickname); - }, [nickname]); - - return { nickname, setNickname }; -} - -export default useNickname; diff --git a/src/helpers/usePrevious.js b/src/helpers/usePrevious.js new file mode 100644 index 0000000..8b8ac74 --- /dev/null +++ b/src/helpers/usePrevious.js @@ -0,0 +1,11 @@ +import { useEffect, useRef } from "react"; + +function usePrevious(value) { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }, [value]); + return ref.current; +} + +export default usePrevious; diff --git a/src/helpers/useSession.js b/src/helpers/useSession.js index 0def7a4..164a829 100644 --- a/src/helpers/useSession.js +++ b/src/helpers/useSession.js @@ -6,7 +6,7 @@ import Peer from "../helpers/Peer"; import AuthContext from "../contexts/AuthContext"; -const socket = io("https://broker.owlbear.rodeo"); +const socket = io("https://agent.owlbear.rodeo"); function useSession( partyId, diff --git a/src/helpers/vector2.js b/src/helpers/vector2.js index e0ee334..41be521 100644 --- a/src/helpers/vector2.js +++ b/src/helpers/vector2.js @@ -1,3 +1,5 @@ +import { toRadians, roundTo as roundToNumber } from "./shared"; + export function lengthSquared(p) { return p.x * p.x + p.y * p.y; } @@ -8,7 +10,7 @@ export function length(p) { export function normalize(p) { const l = length(p); - return { x: p.x / l, y: p.y / l }; + return divide(p, l); } export function dot(a, b) { @@ -16,5 +18,201 @@ export function dot(a, b) { } export function subtract(a, b) { - return { x: a.x - b.x, y: a.y - b.y }; + if (typeof b === "number") { + return { x: a.x - b, y: a.y - b }; + } else { + return { x: a.x - b.x, y: a.y - b.y }; + } +} + +export function add(a, b) { + if (typeof b === "number") { + return { x: a.x + b, y: a.y + b }; + } else { + return { x: a.x + b.x, y: a.y + b.y }; + } +} + +export function multiply(a, b) { + if (typeof b === "number") { + return { x: a.x * b, y: a.y * b }; + } else { + return { x: a.x * b.x, y: a.y * b.y }; + } +} + +export function divide(a, b) { + if (typeof b === "number") { + return { x: a.x / b, y: a.y / b }; + } else { + return { x: a.x / b.x, y: a.y / b.y }; + } +} + +export function rotate(point, origin, angle) { + const cos = Math.cos(toRadians(angle)); + const sin = Math.sin(toRadians(angle)); + const dif = subtract(point, origin); + return { + x: origin.x + cos * dif.x - sin * dif.y, + y: origin.y + sin * dif.x + cos * dif.y, + }; +} + +export function rotateDirection(direction, angle) { + return rotate(direction, { x: 0, y: 0 }, angle); +} + +export function min(a) { + return a.x < a.y ? a.x : a.y; +} + +export function max(a) { + return a.x > a.y ? a.x : a.y; +} + +export function roundTo(p, to) { + return { + x: roundToNumber(p.x, to.x), + y: roundToNumber(p.y, to.y), + }; +} + +export function sign(a) { + return { x: Math.sign(a.x), y: Math.sign(a.y) }; +} + +export function abs(a) { + return { x: Math.abs(a.x), y: Math.abs(a.y) }; +} + +export function pow(a, b) { + if (typeof b === "number") { + return { x: Math.pow(a.x, b), y: Math.pow(a.y, b) }; + } else { + return { x: Math.pow(a.x, b.x), y: Math.pow(a.y, b.y) }; + } +} + +export function dot2(a) { + return dot(a, a); +} + +export function clamp(a, min, max) { + return { + x: Math.min(Math.max(a.x, min), max), + y: Math.min(Math.max(a.y, min), max), + }; +} + +// https://iquilezles.org/www/articles/distfunctions2d/distfunctions2d +export function distanceToLine(p, a, b) { + const pa = subtract(p, a); + const ba = subtract(b, a); + const h = Math.min(Math.max(dot(pa, ba) / dot(ba, ba), 0), 1); + const distance = length(subtract(pa, multiply(ba, h))); + const point = add(a, multiply(ba, h)); + return { distance, point }; +} + +// TODO: Fix the robustness of this to allow smoothing on fog layers +// https://www.shadertoy.com/view/MlKcDD +export function distanceToQuadraticBezier(pos, A, B, C) { + let distance = 0; + let point = { x: pos.x, y: pos.y }; + + const a = subtract(B, A); + const b = add(subtract(A, multiply(B, 2)), C); + const c = multiply(a, 2); + const d = subtract(A, pos); + + // Solve cubic roots to find closest points + const kk = 1 / dot(b, b); + const kx = kk * dot(a, b); + const ky = (kk * (2 * dot(a, a) + dot(d, b))) / 3; + const kz = kk * dot(d, a); + + const p = ky - kx * kx; + const p3 = p * p * p; + const q = kx * (2 * kx * kx - 3 * ky) + kz; + let h = q * q + 4 * p3; + + if (h >= 0) { + // 1 root + h = Math.sqrt(h); + const x = divide(subtract({ x: h, y: -h }, q), 2); + const uv = multiply(sign(x), pow(abs(x), 1 / 3)); + const t = Math.min(Math.max(uv.x + uv.y - kx, 0), 1); + point = add(A, multiply(add(c, multiply(b, t)), t)); + distance = dot2(add(d, multiply(add(c, multiply(b, t)), t))); + } else { + // 3 roots but ignore the 3rd one as it will never be closest + // https://www.shadertoy.com/view/MdXBzB + const z = Math.sqrt(-p); + const v = Math.acos(q / (p * z * 2)) / 3; + const m = Math.cos(v); + const n = Math.sin(v) * 1.732050808; + + const t = clamp(subtract(multiply({ x: m + m, y: -n - m }, z), kx), 0, 1); + const d1 = dot2(add(d, multiply(add(c, multiply(b, t.x)), t.x))); + const d2 = dot2(add(d, multiply(add(c, multiply(b, t.y)), t.y))); + distance = Math.min(d1, d2); + if (d1 < d2) { + point = add(d, multiply(add(c, multiply(b, t.x)), t.x)); + } else { + point = add(d, multiply(add(c, multiply(b, t.y)), t.y)); + } + } + + return { distance: Math.sqrt(distance), point: point }; +} + +export function getBounds(points) { + let minX = Number.MAX_VALUE; + let maxX = Number.MIN_VALUE; + let minY = Number.MAX_VALUE; + let maxY = Number.MIN_VALUE; + for (let point of points) { + minX = point.x < minX ? point.x : minX; + maxX = point.x > maxX ? point.x : maxX; + minY = point.y < minY ? point.y : minY; + maxY = point.y > maxY ? point.y : maxY; + } + return { minX, maxX, minY, maxY }; +} + +// Check bounds then use ray casting algorithm +// https://en.wikipedia.org/wiki/Point_in_polygon#Ray_casting_algorithm +// https://stackoverflow.com/questions/217578/how-can-i-determine-whether-a-2d-point-is-within-a-polygon/2922778 +export function pointInPolygon(p, points) { + const { minX, maxX, minY, maxY } = getBounds(points); + if (p.x < minX || p.x > maxX || p.y < minY || p.y > maxY) { + return false; + } + + let isInside = false; + for (let i = 0, j = points.length - 1; i < points.length; j = i++) { + const a = points[i].y > p.y; + const b = points[j].y > p.y; + if ( + a !== b && + p.x < + ((points[j].x - points[i].x) * (p.y - points[i].y)) / + (points[j].y - points[i].y) + + points[i].x + ) { + isInside = !isInside; + } + } + return isInside; +} + +/** + * Returns true if a the distance between a and b is under threshold + * @param {Vector2} a + * @param {Vector2} b + * @param {number} threshold + */ +export function compare(a, b, threshold) { + return lengthSquared(subtract(a, b)) < threshold * threshold; } diff --git a/src/icons/AddIcon.js b/src/icons/AddIcon.js new file mode 100644 index 0000000..88db9d1 --- /dev/null +++ b/src/icons/AddIcon.js @@ -0,0 +1,20 @@ +import React from "react"; + +function AddIcon({ large }) { + return ( + + + + + ); +} + +AddIcon.defaultProps = { large: false }; + +export default AddIcon; diff --git a/src/icons/AddMapIcon.js b/src/icons/AddMapIcon.js deleted file mode 100644 index aebdf6a..0000000 --- a/src/icons/AddMapIcon.js +++ /dev/null @@ -1,18 +0,0 @@ -import React from "react"; - -function AddMapIcon() { - return ( - - - - - ); -} - -export default AddMapIcon; diff --git a/src/icons/BlendOnIcon.js b/src/icons/BlendOnIcon.js index 68c680a..9382f9a 100644 --- a/src/icons/BlendOnIcon.js +++ b/src/icons/BlendOnIcon.js @@ -9,8 +9,8 @@ function BlendOnIcon() { width="24" fill="currentcolor" > - - + + ); } diff --git a/src/icons/BrushFillIcon.js b/src/icons/BrushFillIcon.js new file mode 100644 index 0000000..b0776df --- /dev/null +++ b/src/icons/BrushFillIcon.js @@ -0,0 +1,18 @@ +import React from "react"; + +function BrushFillIcon() { + return ( + + + + + ); +} + +export default BrushFillIcon; diff --git a/src/icons/BrushStrokeIcon.js b/src/icons/BrushStrokeIcon.js new file mode 100644 index 0000000..f626cb7 --- /dev/null +++ b/src/icons/BrushStrokeIcon.js @@ -0,0 +1,18 @@ +import React from "react"; + +function BrushStrokeIcon() { + return ( + + + + + ); +} + +export default BrushStrokeIcon; diff --git a/src/icons/ExpandMoreDotIcon.js b/src/icons/ExpandMoreDotIcon.js new file mode 100644 index 0000000..6259f07 --- /dev/null +++ b/src/icons/ExpandMoreDotIcon.js @@ -0,0 +1,18 @@ +import React from "react"; + +function ExpandMoreDotIcon() { + return ( + + + + + ); +} + +export default ExpandMoreDotIcon; diff --git a/src/icons/FogAddIcon.js b/src/icons/FogAddIcon.js new file mode 100644 index 0000000..aa21d79 --- /dev/null +++ b/src/icons/FogAddIcon.js @@ -0,0 +1,18 @@ +import React from "react"; + +function FogAddIcon() { + return ( + + + + + ); +} + +export default FogAddIcon; diff --git a/src/icons/FogRemoveIcon.js b/src/icons/FogRemoveIcon.js new file mode 100644 index 0000000..af6c2e6 --- /dev/null +++ b/src/icons/FogRemoveIcon.js @@ -0,0 +1,18 @@ +import React from "react"; + +function FogRemoveIcon() { + return ( + + + + + ); +} + +export default FogRemoveIcon; diff --git a/src/icons/FogToggleIcon.js b/src/icons/FogToggleIcon.js new file mode 100644 index 0000000..569288b --- /dev/null +++ b/src/icons/FogToggleIcon.js @@ -0,0 +1,18 @@ +import React from "react"; + +function FogToggleIcon() { + return ( + + + + + ); +} + +export default FogToggleIcon; diff --git a/src/icons/FogToolIcon.js b/src/icons/FogToolIcon.js new file mode 100644 index 0000000..d81bd89 --- /dev/null +++ b/src/icons/FogToolIcon.js @@ -0,0 +1,18 @@ +import React from "react"; + +function FogToolIcon() { + return ( + + + + + ); +} + +export default FogToolIcon; diff --git a/src/icons/GridOffIcon.js b/src/icons/GridOffIcon.js index d7b2950..a1287c2 100644 --- a/src/icons/GridOffIcon.js +++ b/src/icons/GridOffIcon.js @@ -8,8 +8,6 @@ function GridOffIcon() { viewBox="0 0 24 24" width="24" fill="currentcolor" - // Fixes bug with not firing click event when used in a button - style={{ pointerEvents: "none" }} > diff --git a/src/icons/GridOnIcon.js b/src/icons/GridOnIcon.js index 0511b25..1796b30 100644 --- a/src/icons/GridOnIcon.js +++ b/src/icons/GridOnIcon.js @@ -8,8 +8,6 @@ function GridOnIcon() { viewBox="0 0 24 24" width="24" fill="currentcolor" - // Fixes bug with not firing click event when used in a button - style={{ pointerEvents: "none" }} > diff --git a/src/icons/RemoveMapIcon.js b/src/icons/RemoveMapIcon.js new file mode 100644 index 0000000..a7dd3f3 --- /dev/null +++ b/src/icons/RemoveMapIcon.js @@ -0,0 +1,18 @@ +import React from "react"; + +function RemoveMapIcon() { + return ( + + + + + ); +} + +export default RemoveMapIcon; diff --git a/src/icons/ResetMapIcon.js b/src/icons/ResetMapIcon.js new file mode 100644 index 0000000..02ac13b --- /dev/null +++ b/src/icons/ResetMapIcon.js @@ -0,0 +1,18 @@ +import React from "react"; + +function ResetMapIcon() { + return ( + + + + + ); +} + +export default ResetMapIcon; diff --git a/src/icons/SelectMapIcon.js b/src/icons/SelectMapIcon.js new file mode 100644 index 0000000..dbfd967 --- /dev/null +++ b/src/icons/SelectMapIcon.js @@ -0,0 +1,18 @@ +import React from "react"; + +function SelectMapIcon() { + return ( + + + + + ); +} + +export default SelectMapIcon; diff --git a/src/icons/SettingsIcon.js b/src/icons/SettingsIcon.js new file mode 100644 index 0000000..13bceb9 --- /dev/null +++ b/src/icons/SettingsIcon.js @@ -0,0 +1,18 @@ +import React from "react"; + +function SettingsIcon() { + return ( + + + + + ); +} + +export default SettingsIcon; diff --git a/src/icons/ShapeCircleIcon.js b/src/icons/ShapeCircleIcon.js new file mode 100644 index 0000000..c393782 --- /dev/null +++ b/src/icons/ShapeCircleIcon.js @@ -0,0 +1,18 @@ +import React from "react"; + +function ShapeCircleIcon() { + return ( + + + + + ); +} + +export default ShapeCircleIcon; diff --git a/src/icons/ShapeRectangleIcon.js b/src/icons/ShapeRectangleIcon.js new file mode 100644 index 0000000..55d00ee --- /dev/null +++ b/src/icons/ShapeRectangleIcon.js @@ -0,0 +1,18 @@ +import React from "react"; + +function ShapeRectangleIcon() { + return ( + + + + + ); +} + +export default ShapeRectangleIcon; diff --git a/src/icons/ShapeToolIcon.js b/src/icons/ShapeToolIcon.js new file mode 100644 index 0000000..aa4398f --- /dev/null +++ b/src/icons/ShapeToolIcon.js @@ -0,0 +1,20 @@ +import React from "react"; + +function ShapeToolIcon() { + return ( + + + + + + + ); +} + +export default ShapeToolIcon; diff --git a/src/icons/ShapeTriangleIcon.js b/src/icons/ShapeTriangleIcon.js new file mode 100644 index 0000000..86a29d2 --- /dev/null +++ b/src/icons/ShapeTriangleIcon.js @@ -0,0 +1,18 @@ +import React from "react"; + +function ShapeTriangleIcon() { + return ( + + + + + ); +} + +export default ShapeTriangleIcon; diff --git a/src/icons/SnappingOffIcon.js b/src/icons/SnappingOffIcon.js new file mode 100644 index 0000000..a51c22b --- /dev/null +++ b/src/icons/SnappingOffIcon.js @@ -0,0 +1,18 @@ +import React from "react"; + +function SnappingOffIcon() { + return ( + + + + + ); +} + +export default SnappingOffIcon; diff --git a/src/icons/SnappingOnIcon.js b/src/icons/SnappingOnIcon.js new file mode 100644 index 0000000..355831c --- /dev/null +++ b/src/icons/SnappingOnIcon.js @@ -0,0 +1,18 @@ +import React from "react"; + +function SnappingOnIcon() { + return ( + + + + + ); +} + +export default SnappingOnIcon; diff --git a/src/images/DiagonalPattern.png b/src/images/DiagonalPattern.png new file mode 100644 index 0000000..5d44de9 Binary files /dev/null and b/src/images/DiagonalPattern.png differ diff --git a/src/maps/Blank Grid 22x22.jpg b/src/maps/Blank Grid 22x22.jpg new file mode 100755 index 0000000..317b171 Binary files /dev/null and b/src/maps/Blank Grid 22x22.jpg differ diff --git a/src/maps/Grass Grid 22x22.jpg b/src/maps/Grass Grid 22x22.jpg new file mode 100755 index 0000000..93a20fc Binary files /dev/null and b/src/maps/Grass Grid 22x22.jpg differ diff --git a/src/maps/Sand Grid 22x22.jpg b/src/maps/Sand Grid 22x22.jpg new file mode 100755 index 0000000..41995c3 Binary files /dev/null and b/src/maps/Sand Grid 22x22.jpg differ diff --git a/src/maps/Stone Grid 22x22.jpg b/src/maps/Stone Grid 22x22.jpg new file mode 100755 index 0000000..1748205 Binary files /dev/null and b/src/maps/Stone Grid 22x22.jpg differ diff --git a/src/maps/Water Grid 22x22.jpg b/src/maps/Water Grid 22x22.jpg new file mode 100755 index 0000000..83fbc3e Binary files /dev/null and b/src/maps/Water Grid 22x22.jpg differ diff --git a/src/maps/Wood Grid 22x22.jpg b/src/maps/Wood Grid 22x22.jpg new file mode 100755 index 0000000..5aae7a6 Binary files /dev/null and b/src/maps/Wood Grid 22x22.jpg differ diff --git a/src/maps/index.js b/src/maps/index.js new file mode 100644 index 0000000..9fe93d7 --- /dev/null +++ b/src/maps/index.js @@ -0,0 +1,25 @@ +import blankImage from "./Blank Grid 22x22.jpg"; +import grassImage from "./Grass Grid 22x22.jpg"; +import sandImage from "./Sand Grid 22x22.jpg"; +import stoneImage from "./Stone Grid 22x22.jpg"; +import waterImage from "./Water Grid 22x22.jpg"; +import woodImage from "./Wood Grid 22x22.jpg"; + +export const mapSources = { + blank: blankImage, + grass: grassImage, + sand: sandImage, + stone: stoneImage, + water: waterImage, + wood: woodImage, +}; + +export const maps = Object.keys(mapSources).map((key) => ({ + key, + name: key.charAt(0).toUpperCase() + key.slice(1), + gridX: 22, + gridY: 22, + width: 1024, + height: 1024, + type: "default", +})); diff --git a/src/modals/AddMapModal.js b/src/modals/AddMapModal.js deleted file mode 100644 index dedd74c..0000000 --- a/src/modals/AddMapModal.js +++ /dev/null @@ -1,179 +0,0 @@ -import React, { useRef, useState } from "react"; -import { - Box, - Button, - Image as UIImage, - Flex, - Label, - Input, - Text, -} from "theme-ui"; - -import Modal from "../components/Modal"; - -function AddMapModal({ - isOpen, - onRequestClose, - onDone, - onImageUpload, - gridX, - onGridXChange, - gridY, - onGridYChange, - imageLoaded, - mapSource, -}) { - const fileInputRef = useRef(); - - function handleImageUpload(file) { - if (file.name) { - // Match against a regex to find the grid size in the file name - // e.g. Cave 22x23 will return [["22x22", "22", "x", "23"]] - const gridMatches = [...file.name.matchAll(/(\d+) ?(x|X) ?(\d+)/g)]; - if (gridMatches.length > 0) { - const lastMatch = gridMatches[gridMatches.length - 1]; - const matchX = parseInt(lastMatch[1]); - const matchY = parseInt(lastMatch[3]); - if (!isNaN(matchX) && !isNaN(matchY)) { - onImageUpload(file, matchX, matchY); - return; - } - } - } - onImageUpload(file); - } - - function openImageDialog() { - if (fileInputRef.current) { - fileInputRef.current.click(); - } - } - - const [dragging, setDragging] = useState(false); - function handleImageDragEnter(event) { - event.preventDefault(); - event.stopPropagation(); - setDragging(true); - } - - function handleImageDragLeave(event) { - event.preventDefault(); - event.stopPropagation(); - setDragging(false); - } - - function handleImageDrop(event) { - event.preventDefault(); - event.stopPropagation(); - const file = event.dataTransfer.files[0]; - if (file && file.type.startsWith("image")) { - handleImageUpload(file); - } - setDragging(false); - } - - return ( - - { - e.preventDefault(); - onDone(); - }} - onDragEnter={handleImageDragEnter} - > - handleImageUpload(event.target.files[0])} - type="file" - accept="image/*" - style={{ display: "none" }} - ref={fileInputRef} - /> - - - - - - - - onGridXChange(e.target.value)} - /> - - - - onGridYChange(e.target.value)} - /> - - - {mapSource ? ( - - ) : ( - - )} - {dragging && ( - { - e.preventDefault(); - e.stopPropagation(); - e.dataTransfer.dropEffect = "copy"; - }} - onDrop={handleImageDrop} - > - Drop map to upload - - )} - - - - ); -} - -export default AddMapModal; diff --git a/src/modals/SelectMapModal.js b/src/modals/SelectMapModal.js new file mode 100644 index 0000000..89083e2 --- /dev/null +++ b/src/modals/SelectMapModal.js @@ -0,0 +1,346 @@ +import React, { useRef, useState, useEffect, useContext } from "react"; +import { Box, Button, Flex, Label, Text } from "theme-ui"; +import shortid from "shortid"; + +import db from "../database"; + +import Modal from "../components/Modal"; +import MapTiles from "../components/map/MapTiles"; +import MapSettings from "../components/map/MapSettings"; + +import AuthContext from "../contexts/AuthContext"; + +import usePrevious from "../helpers/usePrevious"; + +import { maps as defaultMaps } from "../maps"; + +const defaultMapSize = 22; +const defaultMapState = { + tokens: {}, + // An index into the draw actions array to which only actions before the + // index will be performed (used in undo and redo) + mapDrawActionIndex: -1, + mapDrawActions: [], + fogDrawActionIndex: -1, + fogDrawActions: [], + // Flags to determine what other people can edit + editFlags: ["drawing", "tokens"], +}; + +const defaultMapProps = { + // Grid type + // TODO: add support for hex horizontal and hex vertical + gridType: "grid", +}; + +function SelectMapModal({ + isOpen, + onRequestClose, + onDone, + onMapChange, + onMapStateChange, + // The map currently being view in the map screen + currentMap, +}) { + const { userId } = useContext(AuthContext); + + const wasOpen = usePrevious(isOpen); + + const [imageLoading, setImageLoading] = useState(false); + + // The map selected in the modal + const [selectedMap, setSelectedMap] = useState(null); + const [selectedMapState, setSelectedMapState] = useState(null); + const [maps, setMaps] = useState([]); + // Load maps from the database and ensure state is properly setup + useEffect(() => { + if (!userId) { + return; + } + async function getDefaultMaps() { + const defaultMapsWithIds = []; + for (let i = 0; i < defaultMaps.length; i++) { + const defaultMap = defaultMaps[i]; + const id = `__default-${defaultMap.name}`; + defaultMapsWithIds.push({ + ...defaultMap, + id, + owner: userId, + // Emulate the time increasing to avoid sort errors + created: Date.now() + i, + lastModified: Date.now() + i, + ...defaultMapProps, + }); + // Add a state for the map if there isn't one already + const state = await db.table("states").get(id); + if (!state) { + await db.table("states").add({ ...defaultMapState, mapId: id }); + } + } + return defaultMapsWithIds; + } + + async function loadMaps() { + let storedMaps = await db + .table("maps") + .where({ owner: userId }) + .toArray(); + const sortedMaps = storedMaps.sort((a, b) => b.created - a.created); + const defaultMapsWithIds = await getDefaultMaps(); + const allMaps = [...sortedMaps, ...defaultMapsWithIds]; + setMaps(allMaps); + + // reload map state as is may have changed while the modal was closed + if (selectedMap) { + const state = await db.table("states").get(selectedMap.id); + if (state) { + setSelectedMapState(state); + } + } + } + + if (!wasOpen && isOpen) { + loadMaps(); + } + }, [userId, isOpen, wasOpen, selectedMap]); + + const fileInputRef = useRef(); + + function handleImageUpload(file) { + if (!file) { + return; + } + let fileGridX = defaultMapSize; + let fileGridY = defaultMapSize; + let name = "Unknown Map"; + if (file.name) { + // TODO: match all not supported on safari, find alternative + if (file.name.matchAll) { + // Match against a regex to find the grid size in the file name + // e.g. Cave 22x23 will return [["22x22", "22", "x", "23"]] + const gridMatches = [...file.name.matchAll(/(\d+) ?(x|X) ?(\d+)/g)]; + if (gridMatches.length > 0) { + const lastMatch = gridMatches[gridMatches.length - 1]; + const matchX = parseInt(lastMatch[1]); + const matchY = parseInt(lastMatch[3]); + if (!isNaN(matchX) && !isNaN(matchY)) { + fileGridX = matchX; + fileGridY = matchY; + } + } + } + + // Remove file extension + name = file.name.replace(/\.[^/.]+$/, ""); + // Removed grid size expression + name = name.replace(/(\[ ?|\( ?)?\d+ ?(x|X) ?\d+( ?\]| ?\))?/, ""); + // Clean string + name = name.replace(/ +/g, " "); + name = name.trim(); + } + let image = new Image(); + setImageLoading(true); + + // Copy file to avoid permissions issues + const copy = new Blob([file], { type: file.type }); + // Create and load the image temporarily to get its dimensions + const url = URL.createObjectURL(copy); + image.onload = function () { + handleMapAdd({ + file: copy, + name, + type: "file", + gridX: fileGridX, + gridY: fileGridY, + width: image.width, + height: image.height, + id: shortid.generate(), + created: Date.now(), + lastModified: Date.now(), + owner: userId, + ...defaultMapProps, + }); + setImageLoading(false); + URL.revokeObjectURL(url); + }; + image.src = url; + // Set file input to null to allow adding the same image 2 times in a row + fileInputRef.current.value = null; + } + + function openImageDialog() { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + } + + async function handleMapAdd(map) { + await db.table("maps").add(map); + const state = { ...defaultMapState, mapId: map.id }; + await db.table("states").add(state); + setMaps((prevMaps) => [map, ...prevMaps]); + setSelectedMap(map); + setSelectedMapState(state); + } + + async function handleMapRemove(id) { + await db.table("maps").delete(id); + await db.table("states").delete(id); + setMaps((prevMaps) => { + const filtered = prevMaps.filter((map) => map.id !== id); + setSelectedMap(filtered[0]); + db.table("states").get(filtered[0].id).then(setSelectedMapState); + return filtered; + }); + // Removed the map from the map screen if needed + if (currentMap && currentMap.id === selectedMap.id) { + onMapChange(null, null); + } + } + + async function handleMapSelect(map) { + const state = await db.table("states").get(map.id); + setSelectedMapState(state); + setSelectedMap(map); + } + + async function handleMapReset(id) { + const state = { ...defaultMapState, mapId: id }; + await db.table("states").put(state); + setSelectedMapState(state); + // Reset the state of the current map if needed + if (currentMap && currentMap.id === selectedMap.id) { + onMapStateChange(state); + } + } + + async function handleSubmit(e) { + e.preventDefault(); + if (selectedMap) { + onMapChange(selectedMap, selectedMapState); + onDone(); + } + onDone(); + } + + /** + * Drag and Drop + */ + const [dragging, setDragging] = useState(false); + function handleImageDragEnter(event) { + event.preventDefault(); + event.stopPropagation(); + setDragging(true); + } + + function handleImageDragLeave(event) { + event.preventDefault(); + event.stopPropagation(); + setDragging(false); + } + + function handleImageDrop(event) { + event.preventDefault(); + event.stopPropagation(); + const file = event.dataTransfer.files[0]; + if (file && file.type.startsWith("image")) { + handleImageUpload(file); + } + setDragging(false); + } + + /** + * Map settings + */ + const [showMoreSettings, setShowMoreSettings] = useState(false); + + async function handleMapSettingsChange(key, value) { + const change = { [key]: value, lastModified: Date.now() }; + db.table("maps").update(selectedMap.id, change); + const newMap = { ...selectedMap, ...change }; + setMaps((prevMaps) => { + const newMaps = [...prevMaps]; + const i = newMaps.findIndex((map) => map.id === selectedMap.id); + if (i > -1) { + newMaps[i] = newMap; + } + return newMaps; + }); + setSelectedMap(newMap); + } + + async function handleMapStateSettingsChange(key, value) { + db.table("states").update(selectedMap.id, { [key]: value }); + setSelectedMapState((prevState) => ({ ...prevState, [key]: value })); + } + + return ( + + + handleImageUpload(event.target.files[0])} + type="file" + accept="image/*" + style={{ display: "none" }} + ref={fileInputRef} + /> + + + + + + {dragging && ( + { + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = "copy"; + }} + onDrop={handleImageDrop} + > + Drop map to upload + + )} + + + + ); +} + +export default SelectMapModal; diff --git a/src/modals/SettingsModal.js b/src/modals/SettingsModal.js new file mode 100644 index 0000000..c60cb20 --- /dev/null +++ b/src/modals/SettingsModal.js @@ -0,0 +1,81 @@ +import React, { useState, useContext } from "react"; +import { Box, Label, Flex, Button, useColorMode, Checkbox } from "theme-ui"; + +import Modal from "../components/Modal"; + +import AuthContext from "../contexts/AuthContext"; + +import db from "../database"; + +function SettingsModal({ isOpen, onRequestClose }) { + const { userId } = useContext(AuthContext); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + async function handleEraseAllData() { + await db.delete(); + window.location.reload(); + } + + async function handleClearCache() { + await db.table("maps").where("owner").notEqual(userId).delete(); + // TODO: With custom tokens look up all tokens that aren't being used in a state + window.location.reload(); + } + + const [colorMode, setColorMode] = useColorMode(); + + return ( + <> + + + + + + + + + + + + + setIsDeleteModalOpen(false)} + > + + + + + + + + + + ); +} + +export default SettingsModal; diff --git a/src/routes/Game.js b/src/routes/Game.js index e2c7488..d83c7ca 100644 --- a/src/routes/Game.js +++ b/src/routes/Game.js @@ -1,20 +1,16 @@ -import React, { - useState, - useRef, - useEffect, - useCallback, - useContext, -} from "react"; +import React, { useState, useEffect, useCallback, useContext } from "react"; import { Flex, Box, Text } from "theme-ui"; import { useParams } from "react-router-dom"; +import db from "../database"; + import { omit, isStreamStopped } from "../helpers/shared"; import useSession from "../helpers/useSession"; -import useNickname from "../helpers/useNickname"; +import useDebounce from "../helpers/useDebounce"; -import Party from "../components/Party"; -import Tokens from "../components/Tokens"; -import Map from "../components/Map"; +import Party from "../components/party/Party"; +import Tokens from "../components/token/Tokens"; +import Map from "../components/map/Map"; import Banner from "../components/Banner"; import LoadingOverlay from "../components/LoadingOverlay"; import Link from "../components/Link"; @@ -23,9 +19,13 @@ import AuthModal from "../modals/AuthModal"; import AuthContext from "../contexts/AuthContext"; +import { tokens as defaultTokens } from "../tokens"; + function Game() { const { id: gameId } = useParams(); - const { authenticationStatus } = useContext(AuthContext); + const { authenticationStatus, userId, nickname, setNickname } = useContext( + AuthContext + ); const { peers, socket } = useSession( gameId, @@ -41,83 +41,189 @@ function Game() { * Map state */ - const [mapSource, setMapSource] = useState(null); - const mapDataRef = useRef(null); + const [map, setMap] = useState(null); + const [mapState, setMapState] = useState(null); + const [mapLoading, setMapLoading] = useState(false); - function handleMapChange(mapData, mapSource) { - mapDataRef.current = mapData; - setMapSource(mapSource); - for (let peer of Object.values(peers)) { - peer.connection.send({ id: "map", data: mapDataRef.current }); + const canEditMapDrawing = + map !== null && + mapState !== null && + (mapState.editFlags.includes("drawing") || map.owner === userId); + + const canEditFogDrawing = + map !== null && + mapState !== null && + (mapState.editFlags.includes("fog") || map.owner === userId); + + const disabledMapTokens = {}; + // If we have a map and state and have the token permission disabled + // and are not the map owner + if ( + mapState !== null && + map !== null && + !mapState.editFlags.includes("tokens") && + map.owner !== userId + ) { + for (let token of Object.values(mapState.tokens)) { + if (token.owner !== userId) { + disabledMapTokens[token.id] = true; + } } } - const [mapTokens, setMapTokens] = useState({}); + // Sync the map state to the database after 500ms of inactivity + const debouncedMapState = useDebounce(mapState, 500); + useEffect(() => { + if ( + debouncedMapState && + debouncedMapState.mapId && + map && + map.owner === userId + ) { + db.table("states").update(debouncedMapState.mapId, debouncedMapState); + } + }, [map, debouncedMapState, userId]); - function handleMapTokenChange(token) { - if (!mapSource) { + function handleMapChange(newMap, newMapState) { + setMapState(newMapState); + setMap(newMap); + for (let peer of Object.values(peers)) { + // Clear the map so the new map state isn't shown on an old map + peer.connection.send({ id: "map", data: null }); + peer.connection.send({ id: "mapState", data: newMapState }); + sendMapDataToPeer(peer, newMap); + } + } + + function sendMapDataToPeer(peer, mapData) { + // Omit file from map change, receiver will request the file if + // they have an outdated version + if (mapData.type === "file") { + const { file, ...rest } = mapData; + peer.connection.send({ id: "map", data: rest }); + } else { + peer.connection.send({ id: "map", data: mapData }); + } + } + + function handleMapStateChange(newMapState) { + setMapState(newMapState); + for (let peer of Object.values(peers)) { + peer.connection.send({ id: "mapState", data: newMapState }); + } + } + + async function handleMapTokenStateChange(token) { + if (mapState === null) { return; } - setMapTokens((prevMapTokens) => ({ - ...prevMapTokens, - [token.id]: token, + setMapState((prevMapState) => ({ + ...prevMapState, + tokens: { + ...prevMapState.tokens, + [token.id]: token, + }, })); for (let peer of Object.values(peers)) { const data = { [token.id]: token }; - peer.connection.send({ id: "tokenEdit", data }); + peer.connection.send({ id: "tokenStateEdit", data }); } } - function handleMapTokenRemove(token) { - setMapTokens((prevMapTokens) => { - const { [token.id]: old, ...rest } = prevMapTokens; - return rest; + function handleMapTokenStateRemove(token) { + setMapState((prevMapState) => { + const { [token.id]: old, ...rest } = prevMapState.tokens; + return { ...prevMapState, tokens: rest }; }); for (let peer of Object.values(peers)) { const data = { [token.id]: token }; - peer.connection.send({ id: "tokenRemove", data }); + peer.connection.send({ id: "tokenStateRemove", data }); } } - const [mapDrawActions, setMapDrawActions] = useState([]); - // An index into the draw actions array to which only actions before the - // index will be performed (used in undo and redo) - const [mapDrawActionIndex, setMapDrawActionIndex] = useState(-1); - function addNewMapDrawActions(actions) { - setMapDrawActions((prevActions) => { + function addMapDrawActions(actions, indexKey, actionsKey) { + setMapState((prevMapState) => { const newActions = [ - ...prevActions.slice(0, mapDrawActionIndex + 1), + ...prevMapState[actionsKey].slice(0, prevMapState[indexKey] + 1), ...actions, ]; const newIndex = newActions.length - 1; - setMapDrawActionIndex(newIndex); - return newActions; + return { + ...prevMapState, + [actionsKey]: newActions, + [indexKey]: newIndex, + }; }); } + function updateDrawActionIndex(change, indexKey, actionsKey, peerId) { + const newIndex = Math.min( + Math.max(mapState[indexKey] + change, -1), + mapState[actionsKey].length - 1 + ); + + setMapState((prevMapState) => ({ + ...prevMapState, + [indexKey]: newIndex, + })); + return newIndex; + } + function handleMapDraw(action) { - addNewMapDrawActions([action]); + addMapDrawActions([action], "mapDrawActionIndex", "mapDrawActions"); for (let peer of Object.values(peers)) { peer.connection.send({ id: "mapDraw", data: [action] }); } } function handleMapDrawUndo() { - const newIndex = Math.max(mapDrawActionIndex - 1, -1); - setMapDrawActionIndex(newIndex); + const index = updateDrawActionIndex( + -1, + "mapDrawActionIndex", + "mapDrawActions" + ); for (let peer of Object.values(peers)) { - peer.connection.send({ id: "mapDrawIndex", data: newIndex }); + peer.connection.send({ id: "mapDrawIndex", data: index }); } } function handleMapDrawRedo() { - const newIndex = Math.min( - mapDrawActionIndex + 1, - mapDrawActions.length - 1 + const index = updateDrawActionIndex( + 1, + "mapDrawActionIndex", + "mapDrawActions" ); - setMapDrawActionIndex(newIndex); for (let peer of Object.values(peers)) { - peer.connection.send({ id: "mapDrawIndex", data: newIndex }); + peer.connection.send({ id: "mapDrawIndex", data: index }); + } + } + + function handleFogDraw(action) { + addMapDrawActions([action], "fogDrawActionIndex", "fogDrawActions"); + for (let peer of Object.values(peers)) { + peer.connection.send({ id: "mapFog", data: [action] }); + } + } + + function handleFogDrawUndo() { + const index = updateDrawActionIndex( + -1, + "fogDrawActionIndex", + "fogDrawActions" + ); + for (let peer of Object.values(peers)) { + peer.connection.send({ id: "fogDrawIndex", data: index }); + } + } + + function handleFogDrawRedo() { + const index = updateDrawActionIndex( + 1, + "fogDrawActionIndex", + "fogDrawActions" + ); + for (let peer of Object.values(peers)) { + peer.connection.send({ id: "fogDrawIndex", data: index }); } } @@ -125,7 +231,6 @@ function Game() { * Party state */ - const { nickname, setNickname } = useNickname(); const [partyNicknames, setPartyNicknames] = useState({}); function handleNicknameChange(nickname) { @@ -151,34 +256,66 @@ function Game() { function handlePeerData({ data, peer }) { if (data.id === "sync") { - if (mapSource) { - peer.connection.send({ id: "map", data: mapDataRef.current }); + if (mapState) { + peer.connection.send({ id: "mapState", data: mapState }); } - if (mapTokens) { - peer.connection.send({ id: "tokenEdit", data: mapTokens }); - } - if (mapDrawActions) { - peer.connection.send({ id: "mapDraw", data: mapDrawActions }); - } - if (mapDrawActionIndex !== mapDrawActions.length - 1) { - peer.connection.send({ id: "mapDrawIndex", data: mapDrawActionIndex }); + if (map) { + sendMapDataToPeer(peer, map); } } if (data.id === "map") { - const blob = new Blob([data.data.file]); - mapDataRef.current = { ...data.data, file: blob }; - setMapSource(URL.createObjectURL(mapDataRef.current.file)); + const newMap = data.data; + // If is a file map check cache and request the full file if outdated + if (newMap && newMap.type === "file") { + db.table("maps") + .get(newMap.id) + .then((cachedMap) => { + if (cachedMap && cachedMap.lastModified === newMap.lastModified) { + setMap(cachedMap); + } else { + setMapLoading(true); + peer.connection.send({ id: "mapRequest" }); + } + }); + } else { + setMap(newMap); + } } - if (data.id === "tokenEdit") { - setMapTokens((prevMapTokens) => ({ - ...prevMapTokens, - ...data.data, + // Send full map data including file + if (data.id === "mapRequest") { + peer.connection.send({ id: "mapResponse", data: map }); + } + // A new map response with a file attached + if (data.id === "mapResponse") { + setMapLoading(false); + if (data.data && data.data.type === "file") { + // Convert file back to blob after peer transfer + const file = new Blob([data.data.file]); + const newMap = { ...data.data, file }; + // Store in db + db.table("maps") + .put(newMap) + .then(() => { + setMap(newMap); + }); + } else { + setMap(data.data); + } + } + if (data.id === "mapState") { + setMapState(data.data); + } + if (data.id === "tokenStateEdit") { + setMapState((prevMapState) => ({ + ...prevMapState, + tokens: { ...prevMapState.tokens, ...data.data }, })); } - if (data.id === "tokenRemove") { - setMapTokens((prevMapTokens) => - omit(prevMapTokens, Object.keys(data.data)) - ); + if (data.id === "tokenStateRemove") { + setMapState((prevMapState) => ({ + ...prevMapState, + tokens: omit(prevMapState.tokens, Object.keys(data.data)), + })); } if (data.id === "nickname") { setPartyNicknames((prevNicknames) => ({ @@ -187,10 +324,22 @@ function Game() { })); } if (data.id === "mapDraw") { - addNewMapDrawActions(data.data); + addMapDrawActions(data.data, "mapDrawActionIndex", "mapDrawActions"); } if (data.id === "mapDrawIndex") { - setMapDrawActionIndex(data.data); + setMapState((prevMapState) => ({ + ...prevMapState, + mapDrawActionIndex: data.data, + })); + } + if (data.id === "mapFog") { + addMapDrawActions(data.data, "fogDrawActionIndex", "fogDrawActions"); + } + if (data.id === "mapFogIndex") { + setMapState((prevMapState) => ({ + ...prevMapState, + fogDrawActionIndex: data.data, + })); } } @@ -286,6 +435,25 @@ function Game() { } }, [stream, peers, handleStreamEnd]); + /** + * Token data + */ + const [tokens, setTokens] = useState([]); + useEffect(() => { + if (!userId) { + return; + } + const defaultTokensWithIds = []; + for (let defaultToken of defaultTokens) { + defaultTokensWithIds.push({ + ...defaultToken, + id: `__default-${defaultToken.name}`, + owner: userId, + }); + } + setTokens(defaultTokensWithIds); + }, [userId]); + return ( <> @@ -303,19 +471,28 @@ function Game() { onStreamEnd={handleStreamEnd} /> + - setPeerError(null)}> diff --git a/src/routes/Home.js b/src/routes/Home.js index 08b710e..e3c8ce3 100644 --- a/src/routes/Home.js +++ b/src/routes/Home.js @@ -51,7 +51,7 @@ function Home() { Join Game - Beta v1.1.0 + Beta v1.2.0