Files
sendou.ink/app/features/map-planner/components/Planner.tsx
2026-09-06 16:57:29 +03:00

999 lines
24 KiB
TypeScript

import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
import {
DndContext,
DragOverlay,
PointerSensor,
TouchSensor,
useDraggable,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { snapCenterToCursor } from "@dnd-kit/modifiers";
import {
AssetRecordType,
createShapeId,
DefaultStylePanel,
type Editor,
type TLAssetId,
type TLComponents,
type TLImageAsset,
type TLShapeId,
type TLUiStylePanelProps,
Tldraw,
type TldrawOptions,
} from "@tldraw/tldraw";
import clsx from "clsx";
import {
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
LogOut,
Radius,
Square,
} from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { getSpecialWeaponRange } from "~/features/comp-analyzer/core/special-weapon-range";
import { getWeaponRange } from "~/features/comp-analyzer/core/weapon-range";
import { useTheme } from "~/features/theme/core/provider";
import type { LanguageCode } from "~/modules/i18n/config";
import { modesShort } from "~/modules/in-game-lists/modes";
import { stageIds, stagesObj } from "~/modules/in-game-lists/stage-ids";
import type {
MainWeaponId,
ModeShort,
SpecialWeaponId,
StageId,
} from "~/modules/in-game-lists/types";
import {
mainWeaponIds,
specialWeaponIds,
subWeaponIds,
weaponCategories,
} from "~/modules/in-game-lists/weapon-ids";
import {
useSearchParam,
useSearchParamsTyped,
} from "~/modules/search-params/hooks";
import { logger } from "~/utils/logger";
import {
mainWeaponImageUrl,
modeImageUrl,
outlinedMainWeaponImageUrl,
specialWeaponImageUrl,
stageMinimapImageUrlWithEnding,
subWeaponImageUrl,
weaponCategoryUrl,
} from "~/utils/urls";
import { LinkButton, SendouButton } from "../../../components/elements/Button";
import { Image } from "../../../components/Image";
import {
PLANNER_BACKGROUND_STYLES,
PLANNER_PERSISTENCE_KEY,
STAGE_WATER_LEVELS,
} from "../plans-constants";
import { plansSearchParams } from "../plans-search-params";
import type { StageWaterLevel } from "../plans-types";
import styles from "./Planner.module.css";
const DROPPED_IMAGE_SIZE_PX = 45;
const BACKGROUND_WIDTH = 1127;
const BACKGROUND_HEIGHT = 634;
const GAME_UNITS_TO_PX: Record<"MINI" | "OVER", number> = {
MINI: 4.4,
OVER: 8.4,
};
// the menu panel that normally holds undo & redo is hidden, so they are moved to the toolbar
const TLDRAW_OPTIONS: Partial<TldrawOptions> = {
actionShortcutsLocation: "toolbar",
};
const MAIN_WEAPON_URL_PATTERN = /main-weapons-outlined\/(\d+)/;
const SPECIAL_WEAPON_URL_PATTERN = /special-weapons\/(\d+)/;
export function Planner() {
const { t, i18n } = useTranslation(["common"]);
const { htmlThemeClass } = useTheme();
const isWide = i18n.language.startsWith("fr");
const [editor, setEditor] = React.useState<Editor | null>(null);
const [imgOutlined, setImgOutlined] = useSearchParam(
plansSearchParams,
"outlined",
);
const [topCollapsed, setTopCollapsed] = useSearchParam(
plansSearchParams,
"hideTop",
);
const [weaponsCollapsed, setWeaponsCollapsed] = useSearchParam(
plansSearchParams,
"hideWeapons",
);
const [rangesVisible, setRangesVisible] = useSearchParam(
plansSearchParams,
"ranges",
);
const rangeCleanupRef = React.useRef<(() => void) | null>(null);
const [activeDragItem, setActiveDragItem] = React.useState<{
src: string;
previewPath: string;
} | null>(null);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(TouchSensor, {
activationConstraint: {
delay: 200,
tolerance: 5,
},
}),
);
const showRanges = React.useCallback((editorToUse: Editor) => {
const gameUnitsToPx = GAME_UNITS_TO_PX[canvasBackgroundStyle(editorToUse)];
removeRangeCircles(editorToUse);
for (const shape of editorToUse.getCurrentPageShapes()) {
createRangeCircleForShape(editorToUse, shape, gameUnitsToPx);
}
const unsubCreate = editorToUse.sideEffects.registerAfterCreateHandler(
"shape",
(shape) => {
if (shape.meta.isRangeCircle) return;
createRangeCircleForShape(editorToUse, shape, gameUnitsToPx);
},
);
const unsubChange = editorToUse.sideEffects.registerAfterChangeHandler(
"shape",
(_prev, next) => {
if (next.meta.isRangeCircle) return;
const rangeCircles = editorToUse
.getCurrentPageShapes()
.filter(
(s) =>
s.meta.isRangeCircle === true && s.meta.weaponShapeId === next.id,
);
if (rangeCircles.length === 0) return;
const centerX = next.x + (next.props as { w: number }).w / 2;
const centerY = next.y + (next.props as { h: number }).h / 2;
for (const rangeCircle of rangeCircles) {
const radiusPx = (rangeCircle.props as { w: number }).w / 2;
editorToUse.updateShape({
id: rangeCircle.id,
type: rangeCircle.type,
isLocked: false,
});
editorToUse.updateShape({
id: rangeCircle.id,
type: rangeCircle.type,
x: centerX - radiusPx,
y: centerY - radiusPx,
isLocked: true,
});
}
},
);
const unsubDelete = editorToUse.sideEffects.registerAfterDeleteHandler(
"shape",
(shape) => {
if (shape.meta.isRangeCircle) return;
const rangeCircles = editorToUse
.getCurrentPageShapes()
.filter(
(s) =>
s.meta.isRangeCircle === true &&
s.meta.weaponShapeId === shape.id,
);
if (rangeCircles.length === 0) return;
for (const rangeCircle of rangeCircles) {
editorToUse.updateShape({
id: rangeCircle.id,
type: rangeCircle.type,
isLocked: false,
});
}
editorToUse.deleteShapes(rangeCircles);
},
);
rangeCleanupRef.current = () => {
unsubCreate();
unsubChange();
unsubDelete();
};
}, []);
const hideRanges = React.useCallback((editorToUse: Editor) => {
rangeCleanupRef.current?.();
rangeCleanupRef.current = null;
removeRangeCircles(editorToUse);
}, []);
const handleMount = React.useCallback(
(mountedEditor: Editor) => {
setEditor(mountedEditor);
mountedEditor.user.updateUserPreferences({
locale: ourLanguageToTldrawLanguage(i18n.language),
colorScheme: htmlThemeClass === "dark" ? "dark" : "light",
});
// a restored plan can hold range circles that no side effect handler is watching anymore
mountedEditor.run(
() => {
if (rangesVisible) {
showRanges(mountedEditor);
} else {
removeRangeCircles(mountedEditor);
}
},
{ history: "ignore" },
);
},
[i18n, htmlThemeClass, rangesVisible, showRanges],
);
const handleAddImage = React.useCallback(
({
src,
size,
isLocked,
point,
meta,
cb,
}: {
src: string;
size: number[];
isLocked: boolean;
point: number[];
meta?: { backgroundStyle?: "MINI" | "OVER" };
cb?: () => void;
}) => {
if (!editor) return;
// image shapes reference an asset by id, so copies of the same image only take up memory once
const assetId: TLAssetId = AssetRecordType.createId();
const srcWithOutline = imgOutlined ? `${src}?outline=red` : src;
// follows tldraw's own example, copes well with lots of shapes at once
const imageAsset: TLImageAsset = {
id: assetId,
type: "image",
typeName: "asset",
props: {
name: "img",
src: srcWithOutline,
w: size[0],
h: size[1],
mimeType: null,
isAnimated: false,
},
meta: {},
};
editor.createAssets([imageAsset]);
const shapeId: TLShapeId = createShapeId();
const shape = {
type: "image",
x: point[0],
y: point[1],
isLocked,
id: shapeId,
meta: meta ?? {},
props: {
assetId,
w: size[0],
h: size[1],
},
};
editor.createShape(shape);
cb?.();
},
[editor, imgOutlined],
);
const handleAddWeaponAtPosition = React.useCallback(
(src: string, point: [number, number]) => {
const centeredPoint: [number, number] = [
point[0] - DROPPED_IMAGE_SIZE_PX / 2,
point[1] - DROPPED_IMAGE_SIZE_PX / 2,
];
handleAddImage({
src,
size: [DROPPED_IMAGE_SIZE_PX, DROPPED_IMAGE_SIZE_PX],
isLocked: false,
point: centeredPoint,
cb: () => editor?.setCurrentTool("select"),
});
},
[editor, handleAddImage],
);
const handleDragStart = (event: DragStartEvent) => {
const { src, previewPath } = event.active.data.current as {
src: string;
previewPath: string;
};
setActiveDragItem({ src, previewPath });
};
const handleDragEnd = (event: DragEndEvent) => {
setActiveDragItem(null);
if (!editor) return;
const { active } = event;
const { src } = active.data.current as { src: string };
const pointerPosition = event.activatorEvent as PointerEvent;
const dropX = pointerPosition.clientX + (event.delta?.x ?? 0);
const dropY = pointerPosition.clientY + (event.delta?.y ?? 0);
const pagePoint = editor.screenToPage({ x: dropX, y: dropY });
handleAddWeaponAtPosition(src, [pagePoint.x, pagePoint.y]);
};
const handleRangeToggle = () => {
if (!editor) return;
if (rangesVisible) {
hideRanges(editor);
} else {
showRanges(editor);
}
setRangesVisible(!rangesVisible);
};
const handleAddBackgroundImage = React.useCallback(
(urlArgs: {
stageId: StageId;
mode: ModeShort;
style: "MINI" | "OVER";
waterLevel: StageWaterLevel;
}) => {
if (!editor) return;
editor.mark("pre-background-change");
hideRanges(editor);
setRangesVisible(false);
const shapes = editor.getCurrentPageShapes();
// locked shapes can't be deleted
for (const value of shapes) {
editor.updateShape({ id: value.id, type: value.type, isLocked: false });
}
editor.deleteShapes(shapes);
handleAddImage({
src: stageMinimapImageUrlWithEnding(urlArgs),
size: [BACKGROUND_WIDTH, BACKGROUND_HEIGHT],
isLocked: true,
point: [0, 0],
meta: { backgroundStyle: urlArgs.style },
});
editor.zoomToFit();
},
[editor, handleAddImage, hideRanges, setRangesVisible],
);
// removes all tldraw ui that isnt needed
const tldrawComponents: TLComponents = {
ActionsMenu: null,
ContextMenu: null,
DebugMenu: null,
DebugPanel: null,
HelperButtons: null,
HelpMenu: null,
KeyboardShortcutsDialog: null,
MainMenu: null,
MenuPanel: null,
Minimap: null,
NavigationPanel: null,
PageMenu: null,
SharePanel: null,
StylePanel: CustomStylePanel,
TopPanel: null,
ZoomMenu: null,
};
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div
className={clsx(
styles.topWrapper,
topCollapsed && styles.topWrapperCollapsed,
)}
>
<StageBackgroundSelector onAddBackground={handleAddBackgroundImage} />
<button
type="button"
className={styles.topToggle}
onClick={() => setTopCollapsed(!topCollapsed)}
aria-label={
topCollapsed
? t("common:actions.showMore")
: t("common:actions.hide")
}
>
{topCollapsed ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
</button>
</div>
<div
className={clsx(
styles.weaponsWrapper,
weaponsCollapsed && styles.weaponsWrapperCollapsed,
)}
>
<div
className={clsx(
styles.weaponsSection,
"scrollbar",
isWide && styles.weaponsSectionWide,
)}
>
<OutlineToggle
outlined={imgOutlined}
setImgOutlined={setImgOutlined}
/>
<RangeToggle active={rangesVisible} onToggle={handleRangeToggle} />
<WeaponImageSelector />
</div>
<button
type="button"
className={styles.weaponsToggle}
onClick={() => setWeaponsCollapsed(!weaponsCollapsed)}
aria-label={
weaponsCollapsed
? t("common:actions.showMore")
: t("common:actions.hide")
}
>
{weaponsCollapsed ? (
<ChevronRight size={16} />
) : (
<ChevronLeft size={16} />
)}
</button>
</div>
<div style={{ position: "fixed", inset: 0 }}>
<Tldraw
persistenceKey={PLANNER_PERSISTENCE_KEY}
onMount={handleMount}
components={tldrawComponents}
options={TLDRAW_OPTIONS}
/>
</div>
<DragOverlay dropAnimation={null} modifiers={[snapCenterToCursor]}>
{activeDragItem ? (
<Image
path={activeDragItem.previewPath}
width={DROPPED_IMAGE_SIZE_PX}
height={DROPPED_IMAGE_SIZE_PX}
alt=""
className={styles.dragPreview}
containerClassName={styles.dragPreviewContainer}
/>
) : null}
</DragOverlay>
</DndContext>
);
}
// styled to sit below the header bar, which otherwise blocks clicks on it
function CustomStylePanel(props: TLUiStylePanelProps) {
return (
<div className={props.isMobile ? undefined : styles.stylePanel}>
<DefaultStylePanel {...props} />
</div>
);
}
function OutlineToggle({
outlined,
setImgOutlined,
}: {
outlined?: boolean;
setImgOutlined: (outline: boolean) => void;
}) {
const { t } = useTranslation(["common"]);
const handleClick = () => {
setImgOutlined(!outlined);
};
return (
<SendouButton
variant="minimal"
onClick={handleClick}
icon={<Square />}
className={clsx(
styles.outlineToggleButton,
outlined && styles.outlineToggleButtonOutlined,
)}
>
{outlined ? t("common:actions.outlined") : t("common:actions.noOutline")}
</SendouButton>
);
}
function RangeToggle({
active,
onToggle,
}: {
active: boolean;
onToggle: () => void;
}) {
const { t } = useTranslation(["common"]);
return (
<SendouButton
variant="minimal"
onClick={onToggle}
icon={<Radius />}
className={clsx(
styles.outlineToggleButton,
active && styles.outlineToggleButtonOutlined,
)}
>
{t("common:plans.ranges")}
</SendouButton>
);
}
function DraggableWeaponButton({
id,
src,
imgPath,
previewPath,
alt,
title,
size,
}: {
id: string;
src: string;
imgPath: string;
previewPath: string;
alt: string;
title: string;
size: number;
}) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id,
data: { src, previewPath },
});
return (
<button
type="button"
ref={setNodeRef}
className={clsx(
styles.draggableButton,
isDragging && styles.weaponDragging,
)}
{...listeners}
{...attributes}
>
<Image
alt={alt}
title={title}
path={imgPath}
width={size}
height={size}
/>
</button>
);
}
function WeaponImageSelector() {
const { t } = useTranslation(["weapons", "common", "game-misc"]);
return (
<>
{weaponCategories.map((category) => {
return (
<details key={category.name}>
<summary className={styles.weaponsSummary}>
<Image
path={weaponCategoryUrl(category.name)}
width={24}
height={24}
alt={t(`common:weapon.category.${category.name}`)}
/>
<span className={styles.weaponsSummaryText}>
{t(`common:weapon.category.${category.name}`)}
</span>
</summary>
<div className={styles.weaponsContainer}>
{category.weaponIds.map((weaponId) => {
return (
<DraggableWeaponButton
key={weaponId}
id={`main-${weaponId}`}
src={`${outlinedMainWeaponImageUrl(weaponId)}.avif`}
imgPath={mainWeaponImageUrl(weaponId)}
previewPath={outlinedMainWeaponImageUrl(weaponId)}
alt={t(`weapons:MAIN_${weaponId}`)}
title={t(`weapons:MAIN_${weaponId}`)}
size={36}
/>
);
})}
</div>
</details>
);
})}
<details>
<summary className={styles.weaponsSummary}>
<Image path={subWeaponImageUrl(0)} width={24} height={24} alt="" />
<span className={styles.weaponsSummaryText}>
{t("common:weapon.category.subs")}
</span>
</summary>
<div className={styles.weaponsContainer}>
{subWeaponIds.map((subWeaponId) => {
return (
<DraggableWeaponButton
key={subWeaponId}
id={`sub-${subWeaponId}`}
src={`${subWeaponImageUrl(subWeaponId)}.avif`}
imgPath={subWeaponImageUrl(subWeaponId)}
previewPath={subWeaponImageUrl(subWeaponId)}
alt={t(`weapons:SUB_${subWeaponId}`)}
title={t(`weapons:SUB_${subWeaponId}`)}
size={28}
/>
);
})}
</div>
</details>
<details>
<summary className={styles.weaponsSummary}>
<Image
path={specialWeaponImageUrl(1)}
width={24}
height={24}
alt=""
/>
<span className={styles.weaponsSummaryText}>
{t("common:weapon.category.specials")}
</span>
</summary>
<div className={styles.weaponsContainer}>
{specialWeaponIds.map((specialWeaponId) => {
return (
<DraggableWeaponButton
key={specialWeaponId}
id={`special-${specialWeaponId}`}
src={`${specialWeaponImageUrl(specialWeaponId)}.avif`}
imgPath={specialWeaponImageUrl(specialWeaponId)}
previewPath={specialWeaponImageUrl(specialWeaponId)}
alt={t(`weapons:SPECIAL_${specialWeaponId}`)}
title={t(`weapons:SPECIAL_${specialWeaponId}`)}
size={28}
/>
);
})}
</div>
</details>
<details>
<summary className={styles.weaponsSummary}>
<Image path={modeImageUrl("RM")} width={24} height={24} alt="" />
<span className={styles.weaponsSummaryText}>
{t("common:plans.adder.objective")}
</span>
</summary>
<div className={styles.weaponsContainer}>
{(["TC", "RM", "CB"] as const).map((mode) => {
return (
<DraggableWeaponButton
key={mode}
id={`mode-${mode}`}
src={`${modeImageUrl(mode)}.avif`}
imgPath={modeImageUrl(mode)}
previewPath={modeImageUrl(mode)}
alt={t(`game-misc:MODE_LONG_${mode}`)}
title={t(`game-misc:MODE_LONG_${mode}`)}
size={28}
/>
);
})}
</div>
</details>
</>
);
}
const LAST_STAGE_ID_WITH_IMAGES = 24;
function StageBackgroundSelector({
onAddBackground,
}: {
onAddBackground: (args: {
stageId: StageId;
mode: ModeShort;
style: "MINI" | "OVER";
waterLevel: StageWaterLevel;
}) => void;
}) {
const { t } = useTranslation(["game-misc", "common"]);
const [
{ stage: stageId, mode, style: backgroundStyle, water: waterLevel },
setParams,
] = useSearchParamsTyped(plansSearchParams);
const handleStageIdChange = (newStageId: StageId) => {
setParams({
stage: newStageId,
water: newStageId === stagesObj.MAHI_MAHI_RESORT ? waterLevel : "up",
});
};
return (
<div className={clsx(styles.topSection, "scrollbar planner")}>
<select
className="w-max"
value={stageId}
onChange={(e) => handleStageIdChange(Number(e.target.value) as StageId)}
aria-label="Select stage"
>
{stageIds
.filter((id) => id <= LAST_STAGE_ID_WITH_IMAGES)
.map((optionStageId) => {
return (
<option value={optionStageId} key={optionStageId}>
{t(`game-misc:STAGE_${optionStageId}`)}
</option>
);
})}
</select>
<select
className="w-max"
value={mode}
onChange={(e) => setParams({ mode: e.target.value as ModeShort })}
>
{modesShort.map((optionMode) => {
return (
<option key={optionMode} value={optionMode}>
{t(`game-misc:MODE_LONG_${optionMode}`)}
</option>
);
})}
</select>
<select
className="w-max"
value={backgroundStyle}
onChange={(e) =>
setParams({ style: e.target.value as "MINI" | "OVER" })
}
>
{PLANNER_BACKGROUND_STYLES.map((style) => {
return (
<option key={style} value={style}>
{t(`common:plans.bgStyle.${style}`)}
</option>
);
})}
</select>
{stageId === stagesObj.MAHI_MAHI_RESORT ? (
<select
className="w-max"
value={waterLevel}
onChange={(e) =>
setParams({ water: e.target.value as StageWaterLevel })
}
>
{STAGE_WATER_LEVELS.map((level) => {
return (
<option key={level} value={level}>
{t(`common:plans.waterLevel.${level}`)}
</option>
);
})}
</select>
) : null}
<SendouButton
onClick={() =>
onAddBackground({ style: backgroundStyle, stageId, mode, waterLevel })
}
className="w-max"
>
{t("common:actions.setBg")}
</SendouButton>
<LinkButton to="/" icon={<LogOut />} variant="outlined" shape="square" />
</div>
);
}
// for a new language check tldraw's TRANSLATIONS constant for the matching one, default to english
const ourLanguageToTldrawLanguageMap: Record<LanguageCode, string> = {
"es-US": "es",
"es-ES": "es",
ko: "ko-kr",
nl: "nl",
zh: "zh-cn",
"fr-CA": "fr",
"fr-EU": "fr",
"pt-BR": "pt-br",
// map to itself
da: "da",
de: "de",
en: "en",
he: "he",
it: "it",
ja: "ja",
ru: "ru",
pl: "pl",
};
function ourLanguageToTldrawLanguage(ourLanguageUserSelected: string) {
for (const [ourLanguage, tldrawLanguage] of Object.entries(
ourLanguageToTldrawLanguageMap,
)) {
if (ourLanguage === ourLanguageUserSelected) {
return tldrawLanguage;
}
}
logger.error(`No tldraw language found for: ${ourLanguageUserSelected}`);
return "en";
}
function extractMainWeaponIdFromSrc(src: string): MainWeaponId | null {
const match = src.match(MAIN_WEAPON_URL_PATTERN);
if (!match) return null;
const id = Number(match[1]);
if (!mainWeaponIds.includes(id as MainWeaponId)) return null;
return id as MainWeaponId;
}
function extractSpecialWeaponIdFromSrc(src: string): SpecialWeaponId | null {
const match = src.match(SPECIAL_WEAPON_URL_PATTERN);
if (!match) return null;
const id = Number(match[1]);
if (!specialWeaponIds.includes(id as SpecialWeaponId)) return null;
return id as SpecialWeaponId;
}
function rangeForSrc(
src: string,
): { range: number; blastRadius?: number } | null {
const mainWeaponId = extractMainWeaponIdFromSrc(src);
if (mainWeaponId !== null) {
const result = getWeaponRange(mainWeaponId);
if (result.rangeType === "unsupported" || result.range <= 0) return null;
return { range: result.range, blastRadius: result.blastRadius };
}
const specialWeaponId = extractSpecialWeaponIdFromSrc(src);
if (specialWeaponId !== null) {
const result = getSpecialWeaponRange(specialWeaponId);
if (!result || result.range <= 0) return null;
return { range: result.range, blastRadius: result.blastRadius };
}
return null;
}
function createRangeCircleForShape(
editor: Editor,
shape: ReturnType<Editor["getCurrentPageShapes"]>[number],
gameUnitsToPx: number,
) {
if (shape.type !== "image") return;
const assetId = (shape.props as { assetId?: string }).assetId;
if (!assetId) return;
const asset = editor.getAsset(assetId as TLAssetId);
if (asset?.type !== "image" || !asset.props.src) return;
const rangeResult = rangeForSrc(asset.props.src);
if (!rangeResult) return;
const centerX = shape.x + (shape.props as { w: number }).w / 2;
const centerY = shape.y + (shape.props as { h: number }).h / 2;
if (typeof rangeResult.blastRadius === "number") {
createCircle(editor, {
centerX,
centerY,
radiusPx: (rangeResult.range + rangeResult.blastRadius) * gameUnitsToPx,
color: "blue",
weaponShapeId: shape.id,
});
}
createCircle(editor, {
centerX,
centerY,
radiusPx: rangeResult.range * gameUnitsToPx,
color: "red",
weaponShapeId: shape.id,
});
editor.bringToFront([shape.id]);
}
function createCircle(
editor: Editor,
{
centerX,
centerY,
radiusPx,
color,
weaponShapeId,
}: {
centerX: number;
centerY: number;
radiusPx: number;
color: "red" | "blue";
weaponShapeId: TLShapeId;
},
) {
const diameter = radiusPx * 2;
editor.createShape({
type: "geo",
x: centerX - radiusPx,
y: centerY - radiusPx,
isLocked: true,
opacity: 0.3,
props: {
geo: "ellipse",
w: diameter,
h: diameter,
color,
fill: "solid",
dash: "solid",
size: "s",
},
meta: { isRangeCircle: true, weaponShapeId },
});
}
function canvasBackgroundStyle(editor: Editor): "MINI" | "OVER" {
for (const shape of editor.getCurrentPageShapes()) {
const style = shape.meta.backgroundStyle;
if (style === "MINI" || style === "OVER") return style;
}
return "MINI";
}
function removeRangeCircles(editor: Editor) {
const shapes = editor.getCurrentPageShapes();
const rangeShapes = shapes.filter(
(shape) => shape.meta.isRangeCircle === true,
);
if (rangeShapes.length === 0) return;
for (const rangeShape of rangeShapes) {
editor.updateShape({
id: rangeShape.id,
type: rangeShape.type,
isLocked: false,
});
}
editor.deleteShapes(rangeShapes);
}