From 12f119faa23099e8cc8e457754a185d9b3fa4727 Mon Sep 17 00:00:00 2001 From: Yuxiang-Huang Date: Thu, 25 Dec 2025 13:57:23 -0500 Subject: [PATCH] feat: add support for CircleOverlay --- src/components/Circle.tsx | 90 +++++++++++++++++++++++++++++++ src/components/CircleProps.tsx | 97 ++++++++++++++++++++++++++++++++++ src/stories/Circle.stories.tsx | 49 +++++++++++++++++ 3 files changed, 236 insertions(+) create mode 100644 src/components/Circle.tsx create mode 100644 src/components/CircleProps.tsx create mode 100644 src/stories/Circle.stories.tsx diff --git a/src/components/Circle.tsx b/src/components/Circle.tsx new file mode 100644 index 0000000..a5fd506 --- /dev/null +++ b/src/components/Circle.tsx @@ -0,0 +1,90 @@ +import { useContext, useEffect, useState } from 'react'; +import MapContext from '../context/MapContext'; +import CircleProps from './CircleProps'; + +export default function Circle({ + coordinate, + radius, + + visible = true, + enabled = true, + selected = false, + + onSelect = undefined, + onDeselect = undefined, + + lineDash = [], + lineDashOffset = 0, + lineWidth = 1, + + strokeColor = 'rgb(0, 122, 255)', + strokeOpacity = 1, + + fillColor = 'rgb(0, 122, 255)', + fillOpacity = 0.1, +}: CircleProps) { + const [circle, setCircle] = useState(null); + const map = useContext(MapContext); + + useEffect(() => { + if (map === null) return undefined; + + const { latitude, longitude } = coordinate; + const mapKitCoordinate = new mapkit.Coordinate(latitude, longitude); + const overlay = new mapkit.CircleOverlay(mapKitCoordinate, radius); + map.addOverlay(overlay); + setCircle(overlay); + + return () => { + map.removeOverlay(overlay); + }; + }, [map]); + + // Simple properties + const properties = { visible, enabled, selected }; + Object.entries(properties).forEach(([propertyName, prop]) => { + useEffect(() => { + if (!circle) return; + // @ts-ignore + circle[propertyName] = prop; + }, [circle, prop]); + }); + + // Simple style properties + const styleProperties = { + lineDash, + lineDashOffset, + lineWidth, + + strokeColor, + strokeOpacity, + + fillColor, + fillOpacity, + }; + Object.entries(styleProperties).forEach(([propertyName, prop]) => { + useEffect(() => { + if (!circle) return; + // @ts-ignore + circle.style[propertyName] = prop; + }, [circle, prop]); + }); + + // Events + const events = [ + { name: 'select', handler: onSelect }, + { name: 'deselect', handler: onDeselect }, + ] as const; + events.forEach(({ name, handler }) => { + useEffect(() => { + if (!circle || !handler) return undefined; + + const handlerWithoutParameters = () => handler(); + + circle.addEventListener(name, handlerWithoutParameters); + return () => circle.removeEventListener(name, handlerWithoutParameters); + }, [circle, handler]); + }); + + return null; +} diff --git a/src/components/CircleProps.tsx b/src/components/CircleProps.tsx new file mode 100644 index 0000000..38372f9 --- /dev/null +++ b/src/components/CircleProps.tsx @@ -0,0 +1,97 @@ +import { Coordinate } from '../util/parameters'; + +export default interface CircleProps { + /** + * The coordinate of the circle overlay’s center. + * @see {@link https://developer.apple.com/documentation/mapkitjs/circleoverlay/coordinate} + */ + coordinate: Coordinate; + + /** + * The radius of the circle overlay, in meters. + * @see {@link https://developer.apple.com/documentation/mapkitjs/circleoverlay/radius} + */ + radius: number; + + /** + * A Boolean value that determines whether the circle is visible. + * @see {@link https://developer.apple.com/documentation/mapkitjs/overlay/visible} + */ + visible?: boolean; + + /** + * A Boolean value that determines whether the circle responds to user interaction. + * @see {@link https://developer.apple.com/documentation/mapkitjs/overlay/enabled} + */ + enabled?: boolean; + + /** + * A Boolean value that determines whether the map displays the circle in a selected state. + * @see {@link https://developer.apple.com/documentation/mapkitjs/overlay/selected} + */ + selected?: boolean; + + /** + * Event fired when the circle is selected. + */ + onSelect?: () => void; + + /** + * Event fired when the circle is deselected. + */ + onDeselect?: () => void; + + /** + * The stroke color of the line. Accepts any valid CSS color value. + * The default is `rgb(0, 122, 255)`. + * Set this to `null` to remove the line stroke. + * @see {@link https://developer.apple.com/documentation/mapkitjs/style/strokeColor} + */ + strokeColor?: string | null; + + /** + * The opacity of the stroke as a number between 0 and 1. + * The default value is `1`. + * @see {@link https://developer.apple.com/documentation/mapkitjs/style/strokeOpacity} + */ + strokeOpacity?: number; + + /** + * The line width of the stroke for overlays, in CSS pixels. + * The default value is `1`. + * @see {@link https://developer.apple.com/documentation/mapkitjs/style/lineWidth} + */ + lineWidth?: number; + + /** + * An array defining the line’s dash pattern, where numbers represent line and + * gap lengths in CSS pixels. For example, `[10, 5]` means draw for 10 pixels + * and leave a 5‑pixel gap repeatedly. Set to `[]` for solid lines (default). + * MapKit JS duplicates the array if it has an odd number of elements. + * @see {@link https://developer.apple.com/documentation/mapkitjs/style/lineDash} + */ + lineDash?: number[]; + + /** + * The number of CSS pixels to offset the start of the dash pattern. + * Has no effect when `lineDash` is set to draw solid lines. + * The default value is `0`. + * @see {@link https://developer.apple.com/documentation/mapkitjs/style/lineDashOffset} + */ + lineDashOffset?: number; + + /** + * The fill color used for the shape. Accepts any valid CSS color value. + * The default is `rgb(0, 122, 255)`. + * Set this to `null` for no fill. + * @see {@link https://developer.apple.com/documentation/mapkitjs/style/fillColor} + */ + fillColor?: string | null; + + /** + * The opacity to apply to the fill, as a number between 0 and 1. + * The default value is `0.1`. + * @see {@link https://developer.apple.com/documentation/mapkitjs/style/fillOpacity} + */ + fillOpacity?: number; +} diff --git a/src/stories/Circle.stories.tsx b/src/stories/Circle.stories.tsx new file mode 100644 index 0000000..3b91352 --- /dev/null +++ b/src/stories/Circle.stories.tsx @@ -0,0 +1,49 @@ +import React, { useMemo } from 'react'; +import { Meta, StoryFn } from '@storybook/react'; +import { fn } from '@storybook/test'; +import './stories.css'; +import Map from '../components/Map'; +import { CoordinateRegion } from '../util/parameters'; +import Circle from '../components/Circle'; + +// @ts-ignore +const token = import.meta.env.STORYBOOK_MAPKIT_JS_TOKEN!; + +export default { + title: 'Components/Circle', + component: Circle, + parameters: { + layout: 'fullscreen', + }, + args: { + onSelect: fn(), + onDeselect: fn(), + }, +} as Meta; + +type CircleProps = React.ComponentProps; + +const Template: StoryFn = (args) => { + const initialRegion: CoordinateRegion = useMemo( + () => ({ + centerLatitude: 48, + centerLongitude: 14, + latitudeDelta: 22, + longitudeDelta: 55, + }), + [], + ); + return ( + + + + ); +}; + +export const Default = Template.bind({}); +Default.args = { coordinate: { latitude: 46.52, longitude: 6.57 }, radius: 100000 };