WP Manifestindependent plugin directory
manifest / content / cns-map-suite

CNS Map Suite

Maps for the cns theme

by Marian Maschke · github.com/marian-l-m/cns-map-suite

0stars
0forks

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.zip

A 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 a nodes JSON array of vertices or arc definitions.
  • wp_cns_map_hierarchy — parent→child map links. Geometry is a nodes polygon 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:

  1. 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.
  2. dbDelta() — WordPress's schema manager — handles LONGTEXT reliably. Native JSON column types are not well-supported by dbDelta() and can cause unexpected behaviour on schema updates.
  3. WordPress core uses the same pattern for post_content and serialised meta_value fields.

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 straight lineTo() calls, closed with closePath().
  • BEZIER — same vertex data rendered with quadraticCurveTo() between midpoints, producing smooth rounded outlines without extra data.
  • CIRCLE — each node is {x, y, radius}, rendered with arc(). 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:

  1. Best practices ✅ Done. manage_maps capability, REST permission_callback, wp_localize_script, $wpdb->prepare() standard, .pot file, readme.txt, uninstall opt-in.
  2. Editor — save ✅ Done. Settings tab fully saves via REST.
  3. 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.
  4. Editor — canvas tools — object placement, area drawing, hierarchy region drawing, property drawers.
  5. Frontend — canvas render — REST endpoint for map data, draw image/objects/areas on <canvas>, hit detection.
  6. Frontend — interactivity — infobox component, post links, MasterMap hover/navigation, responsive scaling, accessibility.