A lightweight, type-safe context menu system for Resium/Cesium applications. Fully controlled via React Context with automatic Cesium event handling.
- π Website
- π― Context-first Architecture - Everything is declaratively controlled via React Context
- π§ Automatic Cesium Integration - Event handlers automatically registered on Cesium canvas
- π¨ Flexible Configuration - Per-entity overrides, type-based factories
- β‘ Async Ready - Supports asynchronous menu generation with loading states
- βΏ Fully Accessible - Keyboard navigation, ARIA roles, focus management
- π¦ TypeScript Support - Type-safe throughout
- π Zero Additional Dependencies - Uses React, Cesium, and Resium (peer dependencies)
npm install resium-entity-context-menu
# or
yarn add resium-entity-context-menu
# or
pnpm add resium-entity-context-menuImportant: Import the CSS file once in your application entry point:
// src/main.tsx or src/index.tsx
import 'resium-entity-context-menu/styles.css';The EntityContextMenu component must be placed inside a Resium <Viewer> as it uses the useCesium() hook to access the Cesium viewer and automatically registers event handlers.
import { Viewer } from 'resium';
import { EntityContextMenuProvider, EntityContextMenu } from 'resium-entity-context-menu';
import { Cartesian3 } from 'cesium';
function App() {
// Default factory for all entities
const defaultFactory = (ctx) => [
{
id: 'info',
label: 'Show Info',
onClick: () => console.log('Entity info:', ctx),
},
];
// Type-specific factories
const factoriesByType = {
city: (ctx) => [
{
id: 'fly',
label: 'Fly Here',
onClick: () => {
// ctx.worldPosition is available for Cesium entities
if (ctx.worldPosition) {
viewer.camera.flyTo({
destination: ctx.worldPosition,
duration: 2,
});
}
},
},
],
};
return (
<EntityContextMenuProvider defaultFactory={defaultFactory} factoriesByType={factoriesByType}>
<Viewer full>
{/* Your Cesium entities here */}
{/* EntityContextMenu MUST be inside Viewer */}
<EntityContextMenu />
</Viewer>
</EntityContextMenuProvider>
);
}import { Entity } from 'resium';
import { useEntityContextMenu } from 'resium-entity-context-menu';
import { Cartesian3, Cartesian2 } from 'cesium';
function CesiumEntity({ position, name, id }) {
const { showMenu } = useEntityContextMenu();
const handleRightClick = (movement, target) => {
if (!target?.id) return;
showMenu({
entityId: target.id.id || id,
entityType: 'city', // or any custom type
position: new Cartesian2(movement.position.x, movement.position.y),
worldPosition: target.id.position?._value || position,
entityData: target.id,
clickedAt: new Date().toISOString(),
});
};
return <Entity position={position} name={name} onRightClick={handleRightClick} />;
}import { useEntityContextMenu } from 'resium-entity-context-menu';
import { useCesium } from 'resium';
import { ScreenSpaceEventHandler, ScreenSpaceEventType, Cartesian2 } from 'cesium';
import { useEffect } from 'react';
function CustomEventHandler() {
const { viewer } = useCesium();
const { showMenu } = useEntityContextMenu();
useEffect(() => {
if (!viewer) return;
const handler = new ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction((movement) => {
const pickedObject = viewer.scene.pick(movement.position);
if (pickedObject?.id) {
showMenu({
entityId: pickedObject.id.id,
entityType: pickedObject.id.type || 'default',
position: new Cartesian2(movement.position.x, movement.position.y),
worldPosition: pickedObject.id.position?._value,
entityData: pickedObject.id,
clickedAt: new Date().toISOString(),
});
}
}, ScreenSpaceEventType.RIGHT_CLICK);
return () => handler.destroy();
}, [viewer, showMenu]);
return null;
}Entities can provide their own menu factory (highest priority):
const berlinEntity = {
id: 'berlin',
type: 'city',
name: 'Berlin',
position: Cartesian3.fromDegrees(13.405, 52.52, 0),
// Entity-specific menu factory (highest priority!)
menuFactory: (ctx) => [
{
id: 'special',
label: 'Berlin Special Actions',
type: 'submenu',
items: [
{
id: 'wiki',
label: 'Open Wikipedia',
onClick: () => window.open('https://en.wikipedia.org/wiki/Berlin', '_blank'),
},
{
id: 'weather',
label: 'Show Weather',
onClick: async (ctx) => {
const weather = await fetchWeather('Berlin');
console.log(weather);
},
},
],
},
],
};Menu resolution follows this priority:
- entity.menuFactory - Entity-specific menu (highest priority)
- factoriesByType[entityType] - Type-based menu
- defaultFactory - Default menu (lowest priority)
type EntityContextMenuProviderProps = {
children: React.ReactNode;
defaultFactory: (ctx: EntityContext) => MenuItem[] | Promise<MenuItem[]>;
factoriesByType?: Record<string, MenuFactory>;
onOpen?: (ctx: EntityContext) => void;
onClose?: () => void;
};type EntityContext = {
entityId: string;
entityType?: string;
position: Cartesian2; // Screen coordinates
worldPosition?: Cartesian3; // 3D world position (optional)
entityData?: any; // The Cesium entity or custom data
clickedAt: string; // ISO timestamp
};type MenuItem = {
id: string;
label: string;
type?: 'action' | 'submenu' | 'toggle' | 'separator' | 'custom';
visible?: (ctx: EntityContext) => boolean;
enabled?: (ctx: EntityContext) => boolean;
onClick?: (ctx: EntityContext) => void | Promise<void>;
items?: MenuItem[]; // for submenus
render?: (ctx: EntityContext) => React.ReactNode; // for custom items
checked?: boolean; // for toggle items
};function useEntityContextMenu(): {
showMenu: (ctx: EntityContext) => void;
hideMenu: () => void;
isVisible: boolean;
context?: EntityContext;
menuItems?: MenuItem[];
isLoading: boolean;
};const cityFactory = async (ctx) => {
// Fetch data from API
const cityData = await fetch(`/api/cities/${ctx.entityId}`).then((r) => r.json());
return [
{
id: 'population',
label: `Population: ${cityData.population.toLocaleString()}`,
onClick: () => showCityDetails(cityData),
},
{
id: 'area',
label: `Area: ${cityData.area} kmΒ²`,
onClick: () => showAreaInfo(cityData),
},
];
};const menuFactory = (ctx) => [
{
id: 'edit',
label: 'Edit Entity',
// Only show for editable entities
visible: (ctx) => ctx.entityData?.editable === true,
// Only enable if not locked
enabled: (ctx) => !ctx.entityData?.locked,
onClick: (ctx) => openEditor(ctx.entityId),
},
{
id: 'delete',
label: 'Delete',
visible: (ctx) => ctx.entityData?.canDelete,
onClick: async (ctx) => {
if (confirm('Delete this entity?')) {
await deleteEntity(ctx.entityId);
}
},
},
];const exportMenu = [
{
id: 'export',
label: 'Export',
type: 'submenu',
items: [
{
id: 'formats',
label: 'Formats',
type: 'submenu',
items: [
{ id: 'json', label: 'JSON', onClick: () => exportAsJSON() },
{ id: 'csv', label: 'CSV', onClick: () => exportAsCSV() },
{ id: 'kml', label: 'KML', onClick: () => exportAsKML() },
],
},
{ id: 'separator', type: 'separator' },
{ id: 'email', label: 'Send via Email', onClick: () => emailExport() },
],
},
];const viewMenu = [
{
id: 'show-label',
label: 'Show Label',
type: 'toggle',
checked: entity.label?.show,
onClick: (ctx) => {
const entity = viewer.entities.getById(ctx.entityId);
if (entity.label) {
entity.label.show = !entity.label.show;
}
},
},
];const customMenu = [
{
id: 'color-picker',
type: 'custom',
render: (ctx) => (
<div style={{ padding: '8px' }}>
<label>Entity Color:</label>
<input
type="color"
defaultValue={ctx.entityData?.color}
onChange={(e) => updateEntityColor(ctx.entityId, e.target.value)}
/>
</div>
),
},
];const menuWithSeparators = [
{ id: 'info', label: 'Show Info', onClick: showInfo },
{ id: 'edit', label: 'Edit', onClick: edit },
{ id: 'sep1', type: 'separator' },
{ id: 'delete', label: 'Delete', onClick: deleteEntity },
];The context menu supports full keyboard navigation:
- β/β - Navigate between menu items
- β - Open submenu / Enter submenu
- β - Close submenu / Return to parent menu
- Enter or Space - Activate menu item
- Escape - Close menu completely
Focus is automatically managed and menu items are properly focused for screen readers.
Import the base styles once in your application entry point:
// src/main.tsx or src/index.tsx
import 'resium-entity-context-menu/styles.css';You can customize the appearance using CSS:
<EntityContextMenu className="my-custom-menu" />/* Override default styles */
.my-custom-menu {
background: #2a2a2a;
border: 1px solid #444;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.my-custom-menu .ecm-item {
color: #fff;
padding: 10px 16px;
}
.my-custom-menu .ecm-item--focused {
background: #404040;
}
.my-custom-menu .ecm-item--enabled:hover {
background: #505050;
}.ecm-menu- Main menu container.ecm-list- Menu items list.ecm-item- Individual menu item.ecm-item--enabled- Enabled item.ecm-item--disabled- Disabled item.ecm-item--focused- Focused item (keyboard navigation).ecm-item--toggle- Toggle-type item.ecm-item--submenu- Item with submenu.ecm-item__label- Item label text.ecm-item__submenu-indicator- Submenu arrow.ecm-separator- Separator line.ecm-submenu- Submenu container.ecm-checkmark- Checkmark for toggle items.ecm-loading- Loading state.ecm-empty- Empty menu state
import { render, screen, fireEvent } from '@testing-library/react';
import {
EntityContextMenuProvider,
EntityContextMenu,
useEntityContextMenu,
} from 'resium-entity-context-menu';
import { Cartesian2 } from 'cesium';
// Mock Cesium/Resium
jest.mock('resium', () => ({
useCesium: () => ({
viewer: {
scene: {
canvas: document.createElement('canvas'),
},
},
}),
}));
test('shows menu on showMenu call', () => {
const TestComponent = () => {
const { showMenu } = useEntityContextMenu();
return (
<button
onClick={() =>
showMenu({
entityId: 'test-entity',
position: new Cartesian2(100, 100),
clickedAt: new Date().toISOString(),
})
}
>
Open Menu
</button>
);
};
const defaultFactory = () => [{ id: 'test', label: 'Test Item', onClick: jest.fn() }];
render(
<EntityContextMenuProvider defaultFactory={defaultFactory}>
<TestComponent />
<EntityContextMenu />
</EntityContextMenuProvider>,
);
fireEvent.click(screen.getByText('Open Menu'));
expect(screen.getByText('Test Item')).toBeInTheDocument();
});import { Viewer, Entity } from 'resium';
import { Cartesian3, Color, Cartesian2 } from 'cesium';
import {
EntityContextMenuProvider,
EntityContextMenu,
useEntityContextMenu,
} from 'resium-entity-context-menu';
import 'resium-entity-context-menu/styles.css';
function CesiumApp() {
const cities = [
{ id: 'berlin', name: 'Berlin', position: Cartesian3.fromDegrees(13.405, 52.52, 0) },
{ id: 'paris', name: 'Paris', position: Cartesian3.fromDegrees(2.3522, 48.8566, 0) },
{ id: 'london', name: 'London', position: Cartesian3.fromDegrees(-0.1276, 51.5074, 0) },
];
const defaultFactory = (ctx) => [
{
id: 'zoom',
label: 'Zoom to Entity',
onClick: () => console.log('Zoom to', ctx.entityId),
},
];
const cityFactory = (ctx) => [
{
id: 'info',
label: `Info: ${ctx.entityData?.name}`,
onClick: () => alert(`City: ${ctx.entityData?.name}`),
},
{
id: 'actions',
label: 'Actions',
type: 'submenu',
items: [
{
id: 'fly',
label: 'Fly Here',
onClick: () => {
// Access viewer from context if needed
console.log('Flying to', ctx.worldPosition);
},
},
{
id: 'highlight',
label: 'Highlight',
onClick: () => console.log('Highlight', ctx.entityId),
},
],
},
];
return (
<EntityContextMenuProvider
defaultFactory={defaultFactory}
factoriesByType={{ city: cityFactory }}
onOpen={(ctx) => console.log('Menu opened for:', ctx.entityId)}
onClose={() => console.log('Menu closed')}
>
<Viewer full timeline={false} animation={false}>
{cities.map((city) => (
<CityEntity key={city.id} city={city} />
))}
<EntityContextMenu />
</Viewer>
</EntityContextMenuProvider>
);
}
function CityEntity({ city }) {
const { showMenu } = useEntityContextMenu();
const handleRightClick = (movement, target) => {
if (!target) return;
showMenu({
entityId: city.id,
entityType: 'city',
position: new Cartesian2(movement.position.x, movement.position.y),
worldPosition: city.position,
entityData: { ...city, editable: true },
clickedAt: new Date().toISOString(),
});
};
return (
<Entity
position={city.position}
name={city.name}
point={{ pixelSize: 10, color: Color.RED }}
onRightClick={handleRightClick}
/>
);
}
export default CesiumApp;- React 18+ (Hooks support)
- Cesium 1.90+ (peer dependency)
- Resium 1.17+ (peer dependency)
- TypeScript 4.0+ (optional but recommended)
-
EntityContextMenu must be inside Viewer: The component uses
useCesium()hook and must be rendered within a Resium<Viewer>component. -
Automatic Event Handling: The component automatically registers event handlers on the Cesium canvas. No need to manually handle right-clicks on the canvas level.
-
Position Format: The
positioninEntityContextmust be aCartesian2(screen coordinates), notCartesian3. -
Import Styles: Don't forget to import
resium-entity-context-menu/styles.cssin your application entry point.
Contributions are welcome! Please feel free to submit a Pull Request.
MIT
Built with β€οΈ for the React/Cesium community