CNS Map Suite
Maps for the cns theme
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/marian-l-m/cns-map-suite/archive/refs/heads/main.zipA WordPress plugin for creating interactive canvas-based maps for the Clouds and Spaceships platform. Maps are authored in a custom admin editor and embedded into posts via a Gutenberg block. Visitors can click icons and drawn areas to open infoboxes and navigate to related content.
Requirements
| Dependency | Minimum |
|---|---|
| WordPress | 6.8 |
| PHP | 8.0 |
| MySQL / MariaDB | 5.7.8 / 10.3 |
| Node.js | 20+ (dev only) |
Getting started
cd wp-content/plugins/cns-map-suite
npm install
npm run build # production build
npm run start # development watch mode
Activate the plugin in WP Admin → Plugins. On first activation the plugin registers the maps custom post type, creates three database tables, and flushes rewrite rules.
Project structure
cns-map-suite/
│
├── cns-map-suite.php # Plugin entry point
├── uninstall.php # Runs only on plugin deletion (not deactivation)
├── package.json # wp-scripts build configuration
│
├── languages/
│ └── cns-map-suite.pot # Translation template (generated by wp i18n make-pot)
│
├── includes/
│ ├── capabilities.php # manage_maps cap — add on activation, remove on uninstall
│ ├── post-type.php # maps CPT + post meta registration
│ ├── database.php # Custom table creation via dbDelta() + prepare() standard
│ └── admin/
│ ├── menu.php # Admin menu registration, asset enqueuing, wp_localize_script
│ ├── api.php # REST route POST /cns-map-suite/v1/maps (create + update)
│ └── views/
│ ├── overview.php # Maps list table with edit/delete actions
│ └── editor.php # 5-tab map editor (Settings functional, others placeholder)
│
├── assets/
│ └── admin/
│ ├── admin.css # Admin UI styles (overview + editor)
│ └── admin.js # Tab switching, MasterMap toggle, media picker, save handler
│
└── src/
└── blocks/
└── map/
├── block.json # Block metadata and attribute schema
├── index.js # registerBlockType entry point
├── edit.js # Block editor UI (map picker)
├── save.js # Returns null — block is server-rendered
├── render.php # Server-side render for frontend output
├── style.scss # Frontend styles
├── editor.scss # Editor-only styles
└── view.js # Frontend interactivity (canvas logic stub)
Architecture and design decisions
Why a custom post type for maps?
Maps are first-class content: they have a title, a thumbnail, descriptive metadata, and a public URL. WordPress's post system handles all of this for free — slugs, revisions, capability checks, REST exposure. The alternative (storing everything in plugin option rows) would mean reimplementing content management that WordPress already does well.
The CPT is registered with show_in_menu: false because the default WP list table is replaced by a purpose-built overview page in the plugin's admin area.
Why is Gutenberg disabled on the maps CPT?
Maps are not document-structured content. Their "body" is a canvas with positioned objects and drawn areas — not a sequence of blocks. Gutenberg would provide no value and would confuse the author workflow. The map editor is an entirely separate UI built to match the task.
Importantly, show_in_rest: true and Gutenberg are independent concerns. The CPT has REST enabled so the block picker in edit.js can query available maps via @wordpress/core-data. Gutenberg being disabled does not affect the REST endpoint.
Why three separate database tables instead of one?
Each table represents a structurally distinct concept:
wp_cns_map_objects— point markers. Geometry is a single(x, y)coordinate.wp_cns_map_areas— polygon/bezier/circle overlays. Geometry is anodesJSON array of vertices or arc definitions.wp_cns_map_hierarchy— parent→child map links. Geometry is anodespolygon on the parent canvas defining where the child map region sits.
Merging these into one table would require many nullable columns and make the shape of a row ambiguous from its data alone. Separate tables keep rows coherent: every column on a map object row is meaningful for a map object.
Why LONGTEXT JSON instead of separate style/node tables?
The previous version of this project (Prisma/PostgreSQL) used a CanvasStyleItem table with five nullable foreign keys — one per entity type it could belong to. This is the polymorphic association anti-pattern: queries are awkward, orphaned rows are easy to create, and indexing the nullable FKs is wasteful because most are always NULL.
canvas_styles, infobox_data, and nodes are stored as LONGTEXT JSON columns on their respective rows instead. This works because:
- We always query these fields by their parent entity (fetch the area, get its nodes). We never need to query across entities by style properties.
dbDelta()— WordPress's schema manager — handlesLONGTEXTreliably. NativeJSONcolumn types are not well-supported bydbDelta()and can cause unexpected behaviour on schema updates.- WordPress core uses the same pattern for
post_contentand serialisedmeta_valuefields.
Why store image width as a relative value (0–1)?
The canvas renders responsively: it scales to its container width. If image position and size were stored in pixels they would only be correct at the exact canvas width they were authored at. Storing _cns_map_image_width as a fraction of canvas width (e.g. 0.75 = 75% of canvas width) means the image scales proportionally at any viewport size. Height is not stored — it is derived at render time from the image's natural aspect ratio.
Why is MasterMap a mode flag on a regular map, not a separate post type?
A MasterMap is still a map: it has a background image, a canvas, and clickable regions. The only difference is what those regions link to — child maps instead of posts. The rendering pipeline, the storage model, and the admin editor are all shared. A separate CPT would duplicate the entire data model and admin UI for a single behavioural difference. A _cns_map_is_master boolean flag on the existing CPT is sufficient.
In the editor, the flag dynamically swaps the Objects and Areas tabs for a Hierarchy tab. On the frontend, the same canvas renderer checks the flag and switches from infobox/post-link behaviour to thumbnail-hover/map-navigate behaviour.
Why is the block dynamic (render.php) rather than static (save.js)?
The block needs to render a <canvas> element with the correct width and height attributes derived from _cns_map_width and _cns_map_aspect_ratio stored in post meta. A static save.js runs at save time in the browser and cannot read post meta. A server-side render.php runs on every page load and can call get_post_meta() directly.
The secondary benefit is forward compatibility: changes to render.php apply immediately to every embedded map without authors needing to re-save their posts.
Admin menu: CNS theme detection
The admin menu checks get_template() during admin_menu at priority 10 — before the CNS theme processes its own tab registry at priority 99. If the theme is active, the plugin adds a Maps entry via the cns_admin_tabs filter, which the theme includes in its unified panel. If the theme is absent, a standalone top-level Maps menu is registered instead.
The map editor (a full-page canvas UI) cannot live inside a settings tab — it needs its own page. When running under the CNS theme, the editor is registered as a sub-page of cns-settings and immediately removed from the visible menu with remove_submenu_page(). This makes it accessible by URL without appearing as a duplicate item in the sidebar.
Activation, deactivation, and uninstall separation
WordPress distinguishes three plugin lifecycle events and they should each do different things:
| Event | Hook | What happens |
|---|---|---|
| Activate | register_activation_hook |
Register CPT, create DB tables, store DB version, flush rewrite rules |
| Deactivate | register_deactivation_hook |
Unregister CPT, flush rewrite rules (removes 404s on CPT URLs) |
| Delete | uninstall.php |
Drop DB tables, delete plugin options |
uninstall.php is preferred over register_uninstall_hook() because it runs in isolation without loading the full plugin — no risk of undefined constants or missing dependencies in an inconsistent state.
Map posts (user content) are intentionally not deleted on uninstall. Silently destroying content is unexpected and difficult to recover from. Infrastructure (tables, options) is removed; content is left for the site owner to handle.
DB version tracking on plugins_loaded
Running dbDelta() only on plugin activation means any schema changes shipped in an update are never applied — the site owner would need to manually deactivate and reactivate. Instead, a cns_map_suite_db_version option is stored and checked on every plugins_loaded. If the stored version differs from the current CNS_MAP_SUITE_DB_VERSION constant, dbDelta() runs again. dbDelta() is additive-only (it adds columns and tables but never removes them), so re-running it is always safe.
Database schema
wp_cns_map_objects — point icon markers
| Column | Type | Notes |
|---|---|---|
id |
BIGINT UNSIGNED |
Primary key |
map_id |
BIGINT UNSIGNED |
→ wp_posts.ID (maps CPT) |
linked_post_id |
BIGINT UNSIGNED |
→ wp_posts.ID (any CPT, optional) |
type |
VARCHAR(20) |
LOCATION | HISTORY | NATURAL | EVENT | OTHER |
svg_slug |
VARCHAR(100) |
Slug from the plugin's predefined SVG set |
icon_image_id |
BIGINT UNSIGNED |
WP attachment ID; overrides svg_slug when set |
title |
VARCHAR(255) |
Display label in admin lists |
x |
INT |
Canvas x coordinate (pixels at authored width) |
y |
INT |
Canvas y coordinate |
object_time |
INT |
In-world timeline value |
infobox_source |
VARCHAR(10) |
manual — use infobox_data; post — pull from linked_post_id |
infobox_data |
LONGTEXT |
JSON {title, description, image_id} used when source is manual |
canvas_styles |
LONGTEXT |
JSON style overrides {label, font, size, opacity, fillStyle, strokeStyle} |
wp_cns_map_areas — polygon / bezier / circle overlays
| Column | Type | Notes |
|---|---|---|
id |
BIGINT UNSIGNED |
Primary key |
map_id |
BIGINT UNSIGNED |
→ wp_posts.ID |
linked_post_id |
BIGINT UNSIGNED |
→ wp_posts.ID (optional) |
type |
VARCHAR(20) |
GEOGRAPHY | POLITICAL | ABSTRACT | INTERACTIVE | OTHER |
shape_type |
VARCHAR(20) |
POLYGON — straight lines; BEZIER — smooth curves; CIRCLE — arc(s) |
title |
VARCHAR(255) |
Admin label |
object_time |
INT |
In-world timeline value |
nodes |
LONGTEXT |
JSON array — [{x,y}] for POLYGON/BEZIER; [{x,y,radius}] for CIRCLE |
background_image_id |
BIGINT UNSIGNED |
WP attachment ID rendered inside the area on the canvas |
infobox_source |
VARCHAR(10) |
manual or post |
infobox_data |
LONGTEXT |
JSON manual infobox content |
canvas_styles |
LONGTEXT |
JSON {fillStyle, strokeStyle, opacity, …} |
Shape type detail:
POLYGON— vertices connected by straightlineTo()calls, closed withclosePath().BEZIER— same vertex data rendered withquadraticCurveTo()between midpoints, producing smooth rounded outlines without extra data.CIRCLE— each node is{x, y, radius}, rendered witharc(). Multiple nodes create compound circular shapes whose union forms the clickable area.
wp_cns_map_hierarchy — MasterMap child links
One row per parent→child relationship. A parent with multiple children has multiple rows — this is intentional normalisation. Storing children as a JSON array in a single row would prevent indexing on child_map_id and make the query "which parent maps contain this child?" impossible without a full table scan.
| Column | Type | Notes |
|---|---|---|
id |
BIGINT UNSIGNED |
Primary key |
parent_map_id |
BIGINT UNSIGNED |
→ wp_posts.ID (must be a MasterMap) |
child_map_id |
BIGINT UNSIGNED |
→ wp_posts.ID (any map) |
nodes |
LONGTEXT |
JSON polygon [{x,y}] — the clickable region on the parent canvas |
canvas_styles |
LONGTEXT |
JSON — hover highlight, thumbnail size, etc. |
UNIQUE KEY (parent_map_id, child_map_id) prevents the same child being linked twice to the same parent.
Maps CPT — wp_postmeta fields
| Key | Type | Purpose |
|---|---|---|
_cns_map_featured |
bool | Show in featured map displays |
_cns_map_width |
int | Canvas max-width in pixels (authored width) |
_cns_map_aspect_ratio |
float | Width ÷ height (e.g. 1.77 for 16∶9) |
_cns_map_time |
int | In-world timeline value for the map itself |
_cns_map_image_id |
int | WP attachment ID — the base map background image |
_cns_map_image_x |
float | Image horizontal offset as fraction of canvas width (0–1) |
_cns_map_image_y |
float | Image vertical offset as fraction of canvas height (0–1) |
_cns_map_image_width |
float | Image width as fraction of canvas width (1.0 = full width) |
_cns_map_is_master |
bool | true = MasterMap mode (links to child maps, not posts) |
_cns_map_bg_type |
string | color — solid fill; image — attachment fills the canvas |
_cns_map_bg_color |
string | Hex colour used when bg_type is color (default #1a1a2e) |
_cns_map_bg_image_id |
int | WP attachment ID used when bg_type is image |
Image position and size are stored as fractions rather than pixels so the layout remains correct at any responsive canvas width.
Admin interface
Maps overview (?page=cns-maps / cns-settings-maps)
Lists all maps in a WP-style list table. Each row shows the base map thumbnail, title, mode badge (Map / MasterMap), featured badge, publish status, creation date, and Edit / Delete actions. The delete action uses a nonce and requires JS confirmation.
Map editor (?page=cns-map-editor[&map_id=N])
Omitting map_id creates a new map. Including it loads the existing map's meta into the form fields.
Tab — Settings Two-column layout: form on the left, live canvas preview on the right (sticky).
The form covers all map-level properties: title, canvas max-width, aspect ratio, base image picker (WP media library), image position and scale, background, MasterMap toggle, and featured flag. Aspect ratio, image X/Y offset, and image width are range sliders with a live numeric readout. The background section has a Color / Image radio toggle: Color mode uses the WP iris colour picker; Image mode uses a media picker and renders the image with cover scaling (fills the canvas, preserves aspect ratio, crops edges — equivalent to background-size: cover).
The live canvas redraws immediately on every input change. It uses a modular draw pipeline: collectDrawState() reads the current form values into a plain object; drawMapCanvas(canvasEl, state) is a reusable async function that renders any canvas from that state; drawEditorCanvas() and drawPreviewCanvas() are thin wrappers that select their respective canvas elements.
The MasterMap toggle hides the Objects and Areas tabs and shows the Hierarchy tab. The Save Map button POSTs to POST /wp-json/cns-map-suite/v1/maps via fetch with a WP REST nonce. Creating a new map redirects to the editor URL with the returned map_id; updating an existing map shows a transient "Saved." confirmation.
Tab — Objects (placeholder) Will contain a canvas drawing surface in "place icons" mode. Clicking the canvas places an SVG icon marker; clicking an existing marker opens a right-side drawer to edit its properties and infobox content.
Tab — Areas (placeholder) Same canvas surface in "draw areas" mode with a sub-mode selector for Polygon, Bezier, and Circle.
Tab — Hierarchy (placeholder, MasterMap only) Canvas surface for drawing regions that link to child maps. Only visible when MasterMap mode is active.
Tab — Preview
Read-only canvas render of the current map state (background + base image). Redraws using the same drawMapCanvas pipeline as the Settings preview whenever the tab is activated.
Block — cns-map-suite/map
Embedded into posts/pages via the block editor. The block stores a single mapId integer attribute referencing a maps CPT post.
In the editor (edit.js): renders a map picker using @wordpress/core-data's getEntityRecords() in the Inspector Controls panel. When no map is selected a <Placeholder> is shown. When a map is selected the block body shows the map title as a preview label.
On the frontend (render.php): reads _cns_map_width and _cns_map_aspect_ratio from post meta to output a <canvas> element with correct dimensions. The data-map-id attribute allows view.js to fetch map object/area data from the REST API and drive the interactive canvas.
The block intentionally has no static save.js output (returns null) — it is fully server-rendered. This means canvas dimensions and map data always reflect the current state of the map post without authors needing to update their embedded blocks.
Development
npm run start # watch mode with HMR
npm run build # production build with blocks-manifest
npm run lint:js # ESLint
npm run lint:css # Stylelint
npm run format # Prettier
The build uses wp-scripts with the --blocks-manifest flag, which generates build/blocks-manifest.php. The main plugin file uses wp_register_block_types_from_metadata_collection() (introduced in WP 6.7) to register all blocks from that manifest in a single call — more efficient than registering each block individually.
Roadmap
See the task list in the project session for the full breakdown. High-level phases:
Best practices✅ Done.manage_mapscapability, RESTpermission_callback,wp_localize_script,$wpdb->prepare()standard,.potfile,readme.txt, uninstall opt-in.Editor — save✅ Done. Settings tab fully saves via REST.Editor — canvas preview✅ Done. Live canvas in Settings tab and Preview tab; background color/image; cover-scaled bg image; range sliders for aspect ratio, image position, and scale.- Editor — canvas tools — object placement, area drawing, hierarchy region drawing, property drawers.
- Frontend — canvas render — REST endpoint for map data, draw image/objects/areas on
<canvas>, hit detection. - Frontend — interactivity — infobox component, post links, MasterMap hover/navigation, responsive scaling, accessibility.