diff --git a/.gitignore b/.gitignore index 8511436..b315b09 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ dist-ssr *.sw? tsconfig.app.tsbuildinfo test-results +tsconfig.node.tsbuildinfo + +docs/.astro/settings.json diff --git a/docs/.astro/settings.json b/docs/.astro/settings.json deleted file mode 100644 index 803259e..0000000 --- a/docs/.astro/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "_variables": { - "lastUpdateCheck": 1727923824793 - } -} \ No newline at end of file diff --git a/docs/src/examples/EventHandlerExample.tsx b/docs/src/examples/EventHandlerExample.tsx new file mode 100644 index 0000000..a4e197f --- /dev/null +++ b/docs/src/examples/EventHandlerExample.tsx @@ -0,0 +1,35 @@ +import React, { useState } from "react"; +import { GeoDataSource, GeoMap } from "../../../src/lib"; + +const EventHandlerExample: React.FC = () => { + const [clickedFeature, setClickedFeature] = useState(""); + const [hoveredFeature, setHoveredFeature] = useState(""); + return ( +
+

EventHandlerExample

+
+ + { + setClickedFeature(feature?.name ?? ""); + }} + onMissed={() => { + setHoveredFeature(""); + }} + onHover={(feature) => { + setHoveredFeature(feature?.name ?? ""); + }} + /> + +
+ +
+ ); +}; + +export default EventHandlerExample; diff --git a/docs/src/examples/HelloWorldExample.tsx b/docs/src/examples/HelloWorldExample.tsx index 0502978..16ce392 100644 --- a/docs/src/examples/HelloWorldExample.tsx +++ b/docs/src/examples/HelloWorldExample.tsx @@ -5,7 +5,10 @@ const HelloWorldExample: React.FC = () => { return (
- +
); diff --git a/docs/src/pages/examples/test-event-handler.astro b/docs/src/pages/examples/test-event-handler.astro new file mode 100644 index 0000000..7985c7f --- /dev/null +++ b/docs/src/pages/examples/test-event-handler.astro @@ -0,0 +1,8 @@ +--- +import EventHandlerExample from "../../../src/examples/EventHandlerExample"; +import ExampleLayout from "../../layouts/ExampleLayout.astro"; +--- + + + + diff --git a/docs/src/pages/index.astro b/docs/src/pages/index.astro index 03b5522..8fffe8e 100644 --- a/docs/src/pages/index.astro +++ b/docs/src/pages/index.astro @@ -41,7 +41,7 @@ function App() {

Why Use React Reconciler?

-

React GeoJSON Map leverages React Reconciler for superior performance and flexibility in geospatial visualization. This approach enables fine-grained control over rendering, custom logic aligned with OpenLayers, and a declarative API for map components. As a result, it offers efficient updates, smooth performance with large datasets, and an intuitive development experience.

+

React GeoJSON Map leverages React Reconciler for superior performance and flexibility in geospatial visualization. This approach enables coarse-grained control over rendering, custom logic aligned with OpenLayers, and a declarative API for map components. As a result, it offers efficient updates, smooth performance with large datasets, and an intuitive development experience.

By using React Reconciler, the library achieves efficient tree updates, better separation of concerns, and reduced overhead compared to React Context. This leads to more granular control over updates, cleaner code separation, and potentially fewer unnecessary re-renders. Consequently, React GeoJSON Map is ideal for building complex, interactive geospatial applications with enhanced performance and maintainability.

diff --git a/package.json b/package.json index 257cb01..83bbd25 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-geojson-map", - "version": "0.1.6", + "version": "0.2.0", "description": "A library for declarative geospatial visualization using React Fiber and OpenLayers", "main": "dist/react-geojson-map.umd.js", "module": "dist/react-geojson-map.es.js", diff --git a/src/lib/components/GeoDataSource.ts b/src/lib/components/GeoDataSource.ts index 2131d92..9185ece 100644 --- a/src/lib/components/GeoDataSource.ts +++ b/src/lib/components/GeoDataSource.ts @@ -2,11 +2,17 @@ import VectorLayer from "ol/layer/Vector"; import { createElement } from "react"; import { DATA_SOURCE } from "../utils/config"; -export interface DataSourceProps { +export interface DataSourceProps extends DataSourceEventHandlers { url: string; fitViewToData?: boolean; } +interface DataSourceEventHandlers { + onHover?: (properties: Record) => void; + onClick?: (properties: Record) => void; + onMissed?: () => void; +} + export default function GeoDataSource(props: DataSourceProps) { const layerConstructor = VectorLayer; diff --git a/src/lib/dataSourceStyleConfig.ts b/src/lib/dataSourceStyleConfig.ts new file mode 100644 index 0000000..973d393 --- /dev/null +++ b/src/lib/dataSourceStyleConfig.ts @@ -0,0 +1,45 @@ +export const dataSourceStyles: { + key: string; + cssVar: string; + fallback: string | number; + parse?: (value: string | number) => number; +}[] = [ + { + key: "fill-color", + cssVar: "--data-source-polygon-fill-color", + fallback: "rgba(126, 188, 111, 0.1)", + }, + { + key: "stroke-color", + cssVar: "--data-source-polygon-stroke-color", + fallback: "rgba(91, 124, 186, 1)", + }, + { + key: "stroke-width", + cssVar: "--data-source-polygon-stroke-width", + fallback: 2, + parse: Number.parseFloat, + }, + { + key: "circle-radius", + cssVar: "--data-source-circle-radius", + fallback: 10, + parse: Number.parseFloat, + }, + { + key: "circle-fill-color", + cssVar: "--data-source-circle-fill-color", + fallback: "rgba(255, 0, 0, 0.5)", + }, + { + key: "circle-stroke-color", + cssVar: "--data-source-circle-stroke-color", + fallback: "rgba(0, 0, 255, 1)", + }, + { + key: "circle-stroke-width", + cssVar: "--data-source-circle-stroke-width", + fallback: 2, + parse: Number.parseFloat, + }, +]; diff --git a/src/lib/index.d.ts b/src/lib/index.d.ts index 6746d97..fe522f2 100644 --- a/src/lib/index.d.ts +++ b/src/lib/index.d.ts @@ -10,6 +10,9 @@ export function GeoMap(props: GeoMapProps): React.ReactElement; export interface DataSourceProps { url: string; fitViewToData?: boolean; + onClick?: (properties?: Record) => void; + onMissed?: () => void; + onHover?: (properties?: Record) => void; } export function GeoDataSource(props: DataSourceProps): React.ReactElement; diff --git a/src/lib/render.ts b/src/lib/render.ts index f397d83..5e3b5fd 100644 --- a/src/lib/render.ts +++ b/src/lib/render.ts @@ -1,16 +1,16 @@ import { Map as OlMap, Overlay } from "ol"; import GeoJSON from "ol/format/GeoJSON"; import VectorSource from "ol/source/Vector"; -import ReactDOM from "react-dom"; import ReactReconciler from "react-reconciler"; import { ConcurrentRoot, DefaultEventPriority, } from "react-reconciler/constants.js"; import type { DataSourceProps } from "./components/GeoDataSource"; +import { renderDataSource } from "./renderers/dataSourceRenderer"; +import { renderPopup } from "./renderers/popupRenderer"; import type { OlInstance, PopupInstance, SupportedLayerType } from "./types"; import { DATA_SOURCE, POPUP } from "./utils/config"; -import { observeCSSVariables } from "./utils/utils"; const roots = new WeakMap(); @@ -21,14 +21,15 @@ function createInstance( try { if (type === DATA_SOURCE) { if (typeof props.layerConstructor === "function") { - const geojsonFormat = new GeoJSON(); - const layer = new props.layerConstructor({ - source: new VectorSource({ - url: props.url, - format: geojsonFormat, - }), + const source = new VectorSource({ + url: props.url, + format: new GeoJSON(), }); + const layer = new props.layerConstructor({ + source, + }) as SupportedLayerType; + return { type, element: layer, props }; } } @@ -51,113 +52,20 @@ function createInstance( function appendChildToContainer(container: OlMap, child: OlInstance) { if (container instanceof OlMap) { if (child.type === DATA_SOURCE) { - const layer = child.element as SupportedLayerType; - - const targetElement = container.getTargetElement() as HTMLElement; - - const applyStyles = () => { - const computedStyle = getComputedStyle(targetElement); - layer.setStyle({ - "fill-color": - computedStyle.getPropertyValue( - "--data-source-polygon-fill-color" - ) || "rgba(126, 188, 111, 0.1)", - "stroke-color": - computedStyle.getPropertyValue( - "--data-source-polygon-stroke-color" - ) || "rgba(91, 124, 186, 1)", - "stroke-width": - Number.parseFloat( - computedStyle.getPropertyValue( - "--data-source-polygon-stroke-width" - ) - ) || 2, - "circle-radius": - Number.parseFloat( - computedStyle.getPropertyValue("--data-source-circle-radius") - ) || 10, - "circle-fill-color": - computedStyle.getPropertyValue("--data-source-circle-fill-color") || - "rgba(255, 0, 0, 0.5)", - "circle-stroke-color": - computedStyle.getPropertyValue( - "--data-source-circle-stroke-color" - ) || "rgba(0, 0, 255, 1)", - "circle-stroke-width": - Number.parseFloat( - computedStyle.getPropertyValue( - "--data-source-circle-stroke-width" - ) - ) || 2, - }); - }; - - container.addLayer(layer); - - const observer = observeCSSVariables(targetElement, applyStyles); - observer.observe(targetElement, { - attributes: true, - attributeFilter: ["style", "class"], - }); - - applyStyles(); - - layer.getSource()?.once("change", () => { - if (layer.getSource()?.getState() === "ready") { - const extent = layer.getSource()?.getExtent(); - - if ( - child.props?.fitViewToData && - extent && - !extent.every((value) => !Number.isFinite(value)) - ) { - container.getView().fit(extent, { - padding: [20, 20, 20, 20], - maxZoom: 19, - duration: 1000, - }); - } - } - }); + renderDataSource(child, container); } else if (child.type === POPUP) { - const popupInstance = child as PopupInstance; - container.addOverlay(popupInstance.popupOverlay); - - if (!popupInstance) - console.error("popupInstance is null, this feature is WIP"); - - container.on("singleclick", (event) => { - const feature = container.forEachFeatureAtPixel( - event.pixel, - (feature) => feature - ); - - if (feature) { - const coordinate = event.coordinate; - popupInstance.popupOverlay.setPosition(coordinate); - ReactDOM.render( - popupInstance.popupFunc( - feature.getProperties() - ) as React.ReactElement, - popupInstance.popupOverlay.getElement() as HTMLElement - ); - } else { - popupInstance.popupOverlay?.setPosition(undefined); - if (popupInstance.popupOverlay?.getElement()) { - ReactDOM.unmountComponentAtNode( - popupInstance.popupOverlay.getElement() as HTMLElement - ); - } - } - }); + renderPopup(child, container); } } } + function removeChild(parent: OlInstance | null, child: OlInstance | null) { - if (parent?.element instanceof OlMap && child) { + if (parent?.element instanceof OlMap && child.type === DATA_SOURCE) { (parent.element as OlMap).removeLayer(child.element as SupportedLayerType); - } - if (child?.element instanceof Overlay) { + (parent.element as OlMap).un("click", child.props.onClick); + (parent.element as OlMap).un("pointermove", child.props.onHover); + (parent.element as OlMap).un("pointermove", child.props.onMissed); + } else if (child?.element instanceof Overlay) { (child as PopupInstance).popupOverlay.setMap(null); } } diff --git a/src/lib/renderers/dataSourceRenderer.ts b/src/lib/renderers/dataSourceRenderer.ts new file mode 100644 index 0000000..5c5bc6a --- /dev/null +++ b/src/lib/renderers/dataSourceRenderer.ts @@ -0,0 +1,74 @@ +import { Map as OlMap } from "ol"; +import { dataSourceStyles } from "../dataSourceStyleConfig"; +import { OlInstance, SupportedLayerType } from "../types"; +import { observeCSSVariables } from "../utils/utils"; + +export const renderDataSource = (child: OlInstance, container: OlMap) => { + const layer = child.element as SupportedLayerType; + + const targetElement = container.getTargetElement() as HTMLElement; + + const styleConfig = dataSourceStyles; + + const applyStyles = () => { + const computedStyle = getComputedStyle(targetElement); + const styles = styleConfig.reduce( + (acc, { key, cssVar, fallback, parse }) => { + const value = computedStyle.getPropertyValue(cssVar) || fallback; + acc[key] = parse ? parse(value) : value; + return acc; + }, + {} as Record + ); + + layer.setStyle(styles); + }; + + container.addLayer(layer); + + const observer = observeCSSVariables(targetElement, applyStyles); + observer.observe(targetElement, { + attributes: true, + attributeFilter: ["style", "class"], + }); + + applyStyles(); + + layer.getSource()?.once("change", () => { + if (layer.getSource()?.getState() === "ready") { + const extent = layer.getSource()?.getExtent(); + + if ( + child.props?.fitViewToData && + extent && + !extent.every((value) => !Number.isFinite(value)) + ) { + container.getView().fit(extent, { + padding: [20, 20, 20, 20], + maxZoom: 19, + duration: 1000, + }); + } + + container.on("click", (event) => { + const features = container.getFeaturesAtPixel(event.pixel); + // TODO:: filter by source url + if (features.length > 0) { + child.props.onClick?.(features[0].getProperties()); + } else { + child.props.onClick?.(); + } + }); + + container.on("pointermove", (event) => { + const features = container.getFeaturesAtPixel(event.pixel); + // TODO:: filter by source url + if (features.length > 0) { + child.props.onHover?.(features[0].getProperties()); + } else { + child.props.onMissed?.(); + } + }); + } + }); +}; diff --git a/src/lib/renderers/popupRenderer.ts b/src/lib/renderers/popupRenderer.ts new file mode 100644 index 0000000..edfca87 --- /dev/null +++ b/src/lib/renderers/popupRenderer.ts @@ -0,0 +1,38 @@ +import { Map as OlMap } from "ol"; +import ReactDOM from "react-dom/client"; +import { OlInstance, PopupInstance } from "../types"; + +// NOTE:: This function is WIP +export const renderPopup = (child: OlInstance, container: OlMap) => { + const popupInstance = child as PopupInstance; + container.addOverlay(popupInstance.popupOverlay); + + if (!popupInstance) + console.error("popupInstance is null, this feature is WIP"); + + container.on("singleclick", (event) => { + const feature = container.forEachFeatureAtPixel( + event.pixel, + (feature) => feature + ); + + if (popupInstance.props.overlay) { + const root = ReactDOM.createRoot( + popupInstance.props.overlayPortalContainer + ); + + if (feature) { + const coordinate = event.coordinate; + popupInstance.popupOverlay.setPosition(coordinate); + root.render( + popupInstance.popupFunc(feature.getProperties()) as React.ReactElement + ); + } else { + popupInstance.popupOverlay?.setPosition(undefined); + if (popupInstance.popupOverlay?.getElement()) { + root.unmount(); + } + } + } + }); +}; diff --git a/tests/DataMapExample.spec.ts b/tests/DataMapExample.spec.ts index 7e6854c..b3b5209 100644 --- a/tests/DataMapExample.spec.ts +++ b/tests/DataMapExample.spec.ts @@ -1,23 +1,21 @@ import { Page, expect, test } from "@playwright/test"; +const URL = "http://localhost:4321/examples/test-map"; +const TIMEOUT = 30000; + +const waitForMap = async (page: Page) => { + await page.waitForSelector(".ol-viewport", { + state: "visible", + timeout: TIMEOUT, + }); + await page.waitForTimeout(1000); +}; + test( "renders map correctly", async ({ page }: { page: Page }) => { - // Remove initial timeout and use navigation timeout instead - await page.goto("http://localhost:4321/examples/test-map", { - timeout: 30000, - }); - - // Wait for the map to be visible and stable - await page.waitForSelector(".ol-viewport", { - state: "visible", - timeout: 30000, - }); - - // Optional: Wait for any animations to complete - await page.waitForTimeout(1000); - - // Take the screenshot and compare + await page.goto(URL, { timeout: TIMEOUT }); + await waitForMap(page); expect(await page.screenshot()).toMatchSnapshot("map.png"); }, { timeout: 100000 } diff --git a/tests/DataSourceExample.spec.ts b/tests/DataSourceExample.spec.ts index 8820549..1324933 100644 --- a/tests/DataSourceExample.spec.ts +++ b/tests/DataSourceExample.spec.ts @@ -1,23 +1,21 @@ import { Page, expect, test } from "@playwright/test"; +const URL = "http://localhost:4321/examples/test-data-source"; +const TIMEOUT = 30000; + +const waitForMap = async (page: Page) => { + await page.waitForSelector(".ol-viewport", { + state: "visible", + timeout: TIMEOUT, + }); + await page.waitForTimeout(1000); +}; + test( "renders map correctly", async ({ page }: { page: Page }) => { - // Remove initial timeout and use navigation timeout instead - await page.goto("http://localhost:4321/examples/test-data-source", { - timeout: 30000, - }); - - // Wait for the map to be visible and stable - await page.waitForSelector(".ol-viewport", { - state: "visible", - timeout: 30000, - }); - - // Optional: Wait for any animations to complete - await page.waitForTimeout(1000); - - // Take the screenshot and compare + await page.goto(URL, { timeout: TIMEOUT }); + await waitForMap(page); expect(await page.screenshot()).toMatchSnapshot("map.png"); }, { timeout: 100000 } diff --git a/tests/EventHandlerExample.spec.ts b/tests/EventHandlerExample.spec.ts new file mode 100644 index 0000000..717c8ec --- /dev/null +++ b/tests/EventHandlerExample.spec.ts @@ -0,0 +1,37 @@ +import { Page, expect, test } from "@playwright/test"; + +const URL = "http://localhost:4321/examples/test-event-handler"; +const TIMEOUT = 30000; +const MOUSE_POSITION = { x: 180, y: 150 }; + +const waitForMap = async (page: Page) => { + await page.waitForSelector(".ol-viewport", { + state: "visible", + timeout: TIMEOUT, + }); + await page.waitForTimeout(1000); +}; + +const performMouseActions = async (page: Page) => { + await page.mouse.move(MOUSE_POSITION.x, MOUSE_POSITION.y); + await page.locator("canvas").click({ + button: "left", + position: MOUSE_POSITION, + }); + await page.waitForTimeout(1000); +}; + +const checkFeatureTexts = async (page: Page) => { + const clickFeatureText = page.getByText("Clicked Feature: GEOJSON Characters"); + const hoverFeatureText = page.getByText("Hovered Feature: GEOJSON Characters"); + + await expect(clickFeatureText).toBeVisible(); + await expect(hoverFeatureText).toBeVisible(); +}; + +test("renders map and handles events correctly", async ({ page }) => { + await page.goto(URL, { timeout: TIMEOUT }); + await waitForMap(page); + await performMouseActions(page); + await checkFeatureTexts(page); +}, { timeout: 100000 }); diff --git a/tsconfig.node.tsbuildinfo b/tsconfig.node.tsbuildinfo deleted file mode 100644 index 98ef2f9..0000000 --- a/tsconfig.node.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"root":["./vite.config.ts"],"version":"5.6.2"} \ No newline at end of file