Search by

uhifadhi / area-module

Area: what an installation manages — the named protected area, its gazetted boundary as a PostGIS multipolygon, and the answer to the seam's area contract so nothing has to be written by hand.

Maintainers

Package info

github.com/uhifadhilabs/area-module

Type:symfony-bundle

pkg:composer/uhifadhi/area-module

Transparency log

Statistics

Installs: 55

Dependents: 2

Suggesters: 0

Stars: 0

Open Issues: 0

v0.6.2 2026-09-05 17:16 UTC

This package is auto-updated.

Last update: 2026-09-05 19:25:02 UTC


README

Area: the named piece of ground an installation manages — its gazetted boundary, and the answer to the seam's area contract so nothing has to be written by hand. A uhifadhi platform module.

Contents

The architecture

Uhifadhi is one skeleton and a set of modules. uhifadhi/uhifadhi is the project skeleton — copied once, never updated; everything else arrives as a module, updated forever. A module registers with the seam (uhifadhi/seam-module) and renders in the shell (uhifadhi/shell-module); everything a deployment can do — patrols, incidents, rosters — is a module.

The skeleton is the application, the seam carries the modules, the shell is what you see, team is who is looking, and this is where. An area is the axis the whole product is filed under: a patrol happens in one, an incident is reported in one, a module is switched on for one. Until this module existed, every installation wrote its own — because the platform asked every installation for an area and shipped none.

What it owns

Two entities and the seams a module contributes to an area through.

The area

Field What it is
name What the area is called
geom The boundary — a MultiPolygon in WGS84, exchanged as GeoJSON
source Where the boundary came from: WDPA, a shapefile's name, drawn
iucnCategory IUCN protected-area category (II, VI, …), optional
establishedYear Year gazetted, optional
uuid The public identifier — every URL and API response names an area by this
createdAt / updatedAt Stamped by lifecycle callbacks

MultiPolygon, not Polygon, because a gazetted boundary is regularly more than one ring: an enclave, an outlying block, a lake excluded from the middle. The column is the fundi-postgis geometry type, so fundistadi/postgis-bundle is a requirement of this module and PostGIS a requirement of the database.

The registry fields are optional, and that is honest rather than lax. An installation that drew its own boundary on a map has no IUCN category and no gazettement year, and must not be made to invent them to save a record.

Addressed by UUID. The sequential id exists so foreign keys are cheap and never appears in a URL. The repository extends the PostGIS bundle's spatial base, so stAreaKm2() and findStIntersecting() are there without this module writing a line of SQL; the one method it adds is findOneByUuid().

The zone

A zone is a named polygon subdividing one area — the spatial lens, the way a department is the organisational one. Zones are data an admin draws or uploads, never code: a module asks generic questions of them ("which zone is this point in?") and never names one, because the names are one installation's geography.

Field What it is
name What the zone is called — unique per area, so two areas may each have a "North"
area The area it subdivides — NOT NULL, and the foreign key cascades
geom A MultiPolygon in WGS84, like the area's
uuid The public identifier
createdAt / updatedAt Stamped by lifecycle callbacks

Sibling zones never share interior. Adjacency is legal — two zones may meet along an edge — and so are gaps, because an area is regularly only partly zoned. That is exactly the DE-9IM pattern T******** and not ST_Overlaps, which PostGIS defines as false when one geometry contains another; containment is a conflict the invariant has to catch. The rule is not expressible as a column constraint, so ZoneService is the only supported way a zone gets a geometry — anything that writes one around it writes an overlap nobody notices until a point falls in two zones at once.

An area with no zones is the normal state, not a configuration somebody forgot. ZoneService::zoneOf() answers null without complaint, and every consumer treats "unzoned" as a first-class answer.

The table is zone, the same compatibility promise area_of_interest carries: an installation that wrote this entity by hand keeps its rows.

What a module contributes to an area

/areas/{uuid} is the first surface in the platform whose widgets are not written by whoever owns the page. The host owns the surface, the grid and the identity of the area; every operational widget arrives from a module installed in that area. These are the seams it arrives through.

Seam Tag What a module puts on the page
Overview\OverviewContributorInterface uhifadhi.overview.widget_provider Its own widgets, and the library section they sit under
Overview\NowTileProviderInterface uhifadhi.overview.now_tile A tile in the right-now strip
Overview\AttentionProviderInterface uhifadhi.overview.attention A row in "Needs attention"
Overview\MapLayerProviderInterface uhifadhi.map.layer A layer on the operational plate, with its legend
Overview\PulseProviderInterface uhifadhi.overview.pulse Its moves in the area pulse
Overview\OverviewCopyProviderInterface uhifadhi.overview.copy Its own words inside a host sentence
Overview\ContributesStylesheetInterface The stylesheet its markup needs, since somebody else renders it
Kpi\DepartmentKpiProviderInterface uhifadhi.department_kpi A figure on a department's performance surfaces

Absent is never zero. Every one of these may answer [], and that is the right answer rather than a gap: a module with nothing to say puts no tile in the strip instead of a tile reading 0, and an unmeasured figure renders as a dashed slot instead of a 0% that claims a measurement nobody took.

Tag explicitly, and write the tag as a literal. A reusable bundle is not autoconfigured, so a contributing module tags its provider by hand in its own extension — and writes 'uhifadhi.overview.now_tile' rather than reading NowTileProviderInterface::TAG, because reading the constant loads a class from a package that need not be on its build classpath. The constant and the literal are kept equal by tests/Unit/Overview/ContributionSeamContractTest.php; change one without the other and contributions land in a tag nobody collects, with no error anywhere.

Why these interfaces live here

An interface a module implements is normally hoisted into uhifadhi/module-contracts, and these are not. The precedent followed instead is uhifadhi/widget-module's: WidgetSurfaceInterface lives in the package that owns the surface, and a consumer hard-requires that package.

The tension is real and worth stating. Hoisting exists so two modules can agree on a promise without either depending on the other, and by that rule these belong in contracts. What decides it the other way is that every one of these methods takes an AreaOfInterest: an area-scoped module needs the area entity anyway, so requiring this package costs it a dependency it already has, while hoisting would put an interface in contracts whose signature names a class contracts does not publish. If a department contract is ever published and the KPI seam stops needing an area, that argument weakens and the hoist should be revisited.

Departments arrive as a ref, not as an entity. DepartmentKpiProviderInterface takes a Kpi\DepartmentRef — an id, a uuid and a name — because departments belong to uhifadhi/team-module and no package publishes a contract for one. Typing the seam against Uhifadhi\Team\Entity\Department would make every module that reports a figure hard-require team; typing it against nothing would hand providers an object to guess at. The caller resolves the ref, and a provider that needs to know whose rows these are reads the org chart the way the fleet already does — off the mapping, through the class the installation resolved the user contract to, never through a getPosition() no contract promises.

The screens

Seven, mounted by the recipe's config/routes/area.yaml and yours to prefix, restrict or remove.

Screen Route Gate
The register — every area area_index · /areas area.view
Create an area area_new · GET,POST /areas/new area.create
One area's overview area_show · /areas/{uuid} area.view
Its module grid seam_area_modules · /areas/{uuid}/modules module.view
Its module shop area_module_customize · /areas/{uuid}/modules/customize module.create
Its zones area_zones · /areas/{uuid}/zones area.view
Its settings area_settings · /areas/{uuid}/settings area.edit

Three POST addresses sit under the shop and carry the same module.create gate plus a CSRF token scoped to the area: area_module_install, area_module_uninstall and area_module_reorder.

Gated on the platform's permission strings and on nothing else. area.view, area.create, area.edit, area.delete, module.view and module.create are in the catalogue uhifadhi/team-module ships, and team's voter answers them at runtime — this module names them and does not depend on team, so an installation may answer them with something else without touching a screen.

Addressed by UUID. Every route carries the uuid requirement, so /areas/2 is a 404 rather than a sequential key anybody can walk.

The shell is a suggest, not a require. These screens render in the shell's frame where an installation has one. The wiring for them lives in config/screens.php and is imported only where the application has both TwigBundle and SecurityBundle — an installation that wants the area MODEL and no pages (a console importer, an API) still boots, where a controller depending on a non-existent twig service would have failed at compile time.

Where you are, and what is in the sidebar

This module answers two of the shell's seams, and both read one list so they cannot disagree:

  • AreaShellSourceInterface — the tab strip above an area, and the area's name for the page title. Aliased to shell.area_shell_source; an installation whose areas are its own model aliases that id to its own class instead.
  • NavigationSourceInterface — the Observatory → Areas section, the register and every area unfolding to its own screens. Tagged shell.nav_section.

Both are route-tolerant: unmount a route and its tab or row is simply absent rather than every page failing. That is deliberate, so removing a screen is a supported thing to do. The Modules tab appears only where an installation mounts a seam_area_modules route — which this module has mounted since v0.5.0, and which you may still unmount.

A tab or row the viewer may not have is ABSENT, never greyed out. A disabled "Settings" tells a ranger a screen exists and they are not trusted with it, which is a worse product than not mentioning it.

What the register shows, and what it deliberately does not

The register lists only what this module owns: the area's identity, its measured size and centre, how it is subdivided, and what the seam has switched on in it.

The design draws more — forest cover, tree-cover loss, an ha/yr rate, a trend sparkline and an alerts chip. Those columns are absent here, and the reason is architectural rather than a missing feed. The design was drawn for the monolith, where every one of those figures was the same application's to read. In a fleet of modules they belong to a future ingestion-shaped module, and a register that reserved labelled columns for them would be this module knowing what that module measures — precisely the coupling the overview's contribution seams exist to prevent.

Per-module register columns are a contribution seam that does not exist yet. When it lands, a module will add its own column the way it already adds its own overview widget and its own now-tile. Until then the columns are ABSENT rather than empty: a permanently dashed "forest" column is furniture that teaches somebody the product is broken.

The area thumbnails the design draws are also absent — rendering one is uhifadhi/map-module's business, and this module does not depend on it.

Creating an area

A name and a GeoJSON boundary, gated on area.create — and the button on the register carries the same gate, because a control that opens onto a refusal is a worse answer than no control.

The file may be a Polygon, a MultiPolygon, a Feature or a FeatureCollection; all four become the one MultiPolygon the column takes, because a gazetted boundary is regularly more than one piece of ground — an enclave, an outlying block. Several features are merged into one boundary rather than the first one taken.

GeoJSON only, and the screen says so. The application this was ported from also accepted zipped Shapefiles, GeoPackages, KML/KMZ and File Geodatabases by shelling out to GDAL's ogr2ogr. A reusable bundle will not put a system package between an installation and its first area, so that path is not here; the drop zone's caption names what this module can actually read rather than five formats it would refuse four of.

Nothing is reprojected. RFC 7946 defines GeoJSON as WGS84, which is what the column's typmod declares. Nothing is parsed in PHP either: the geometry reaches PostGIS as GeoJSON and ST_GeomFromGeoJSON decides validity at the insert, with the whole boundary in hand.

A refusal is the same page with a sentence on it and a 422 — never a redirect and never an error page. The typed name survives it.

The form carries a CSRF token, like every other write in this module. area.create answers who may create an area; the token answers whether this page asked, and a permission is no defence against a form on somebody else's site posting here with the viewer's own cookie.

The module grid and the shop

seam_area_modules is a fleet route name, not this module's invention. It existed as a name before it existed as a route: this module's own tab strip has listed it since v0.4.0, and the patrol module's breadcrumb and dashboard back-button generate it blind and print plain text when nothing answers. Mounting it here lights all three with no change to any of them.

This module owns the screen; the seam stays UI-less. The grid is a reading of the seam's catalogue against an area's ledger, and "an area" is this module's word — the seam holds that table for installations whose area model is their own and cannot name an area class, let alone draw a page about one. So the seam publishes the data and this module draws it.

The picture is the shell's. Tiles render through @UhifadhiShell/_module_grid.html.twig, the same partial a department page would use, because the catalogue picture must look identical wherever it appears. What is not the shell's is which cards, in which groups, with which URLs: that needs the area, the viewer and the ledger. See src/Service/AreaComposition.php.

The grid shows what is ON. A tile for a parked module would open onto a page the area has switched off; parked modules live in the shop, which is the screen for changing your mind about them. A tile whose module declares no entry route, or whose route this installation has not mounted, is inert rather than a link.

Every write goes through the seam's AreaModuleService — nothing here writes an area_module row by hand, because the rule that a pinned module cannot be parked lives there and a second writer would eventually disagree with it. The seam needed no new operation for this screen: install, uninstall and reorder were already published.

Two permissions, and the mapping is deliberate. module.view to see the grid; module.create — the catalogue's Modules / Add — to reach the shop and to move anything in it. The application this was ported from gated its customize screen on ROLE_ADMIN while its own docblock claimed module.create; the catalogue string is the honest one, because composing an area is exactly the capability it describes and a role is not grantable to a position.

The tiles carry no headline figure, and that is a missing seam rather than a missing style. The design draws a statistic, a sparkline and a one-line summary on each tile. Every one is a module's own reading of its own data, and there is no contribution seam for them — the catalogue publishes a slug, a name, a category, a status and a data source, and that is all anybody can honestly print. When a module can contribute a tile figure the way it already contributes an overview widget, the tiles grow one and the template does not change.

The stylesheet

bundles/uhifadhiarea/area.css, named by UhifadhiAreaBundle::STYLESHEET and linked by templates/_stylesheets.html.twig after the shell's own. It declares no colours and no fonts of its own — every value is one of the shell's --c-* tokens or --font-mono — so theming the shell themes these screens and dark mode needs nothing here.

Whoever knows the answer states the resolution

The rule, for the whole fleet: the package that provides the answer is the package that states the resolution. Team knows what its User is and prepends that mapping; area knows what its AreaOfInterest is and prepends this one. An installation writes a resolve_target_entities line only when it wants to disagree — and then its line wins, because prepended configuration loses to the application's by Symfony's own design.

The corollary is the point of this ring: once the answer-modules are installed, a bare installation reaches doctrine:migrations:diff with zero doctrine edits.

Here is the seam's half of it. The seam owns the record of which modules an area has switched on, and it cannot name an area class — it holds that table for installations whose area model is their own — so it maps the association at Uhifadhi\Seam\Entity\AreaInterface. Somebody has to close the loop. For as long as this module is installed the answer is not in doubt:

# what the bundle prepends for you — you do not write this
doctrine:
    orm:
        resolve_target_entities:
            Uhifadhi\Seam\Entity\AreaInterface: Uhifadhi\Area\Entity\AreaOfInterest

It used to be a documented hand-step, and it was the fleet's oldest: write a placeholder class in src/Entity/, then uncomment a block in config/packages/seam.yaml. That was the wrong shape twice over. A hand-step is for a decision only the installation can make, and "what is an area" was only a decision because nothing shipped one. And its cost was real, because forgetting either half fails a long way from its cause — the container compiles, the kernel boots, and then:

$ bin/console doctrine:migrations:diff
In MappingException.php line 72:
  Class 'Uhifadhi\Seam\Entity\AreaInterface' does not exist

with nothing pointing back at the paragraph that was missed.

If your areas are your own entity, say so and you win. Name your class in your own config and there is nothing here to disable first:

# config/packages/doctrine.yaml — yours, and it overrules the bundle
doctrine:
    orm:
        resolve_target_entities:
            Uhifadhi\Seam\Entity\AreaInterface: App\Entity\ManagementUnit

Merge it into the block already there — that file opens with doctrine:, and a second doctrine: key in the same file is not valid YAML; resolve_target_entities goes under the existing orm:, beside mappings. Your class has to answer getId() and be in the mapping chain. That the override wins is tested, not assumed (tests/Integration/Resolution/ResolveTargetEntitiesTest).

Why it declares no module

An area is not a module of itself. Every other bundle in the fleet carries the uhifadhi.module tag and takes a tile in the catalogue. The catalogue is indexed by area — the seam's area_module row says "this area has this module switched on" — so a provider here would write a row for every area saying that the area has areas: switchable, meaningless, and shown in the module grid of the page it is the subject of.

Areas are the axis the catalogue is indexed by, not an entry in it. This is the thing an area is, not a capability an area has.

That axis role is why area belongs to the infrastructure tier rather than the capability tier. The fleet has two: a capability module (patrol, incident) is the per-area grid an admin switches on, default off, ledgered per area; an infrastructure module (area, map, widget, storage, team) is machinery every relevant screen already relies on — installed means on, never in the catalogue, grid, or ledger. Neither area nor map is a per-area toggle, so neither carries a uhifadhi.module provider.

Because an area's boundary is drawn on its own map, this bundle hard-requires uhifadhi/map-module (infrastructure, guaranteed present through the composer graph rather than a ledger row). Map is therefore never absent, so nothing here carries an "if the map is available" branch: the boundary is area's own data — a base layer, not a module contribution — and capability modules (patrol tracks, incident pins) add their layers on top when switched on.

What this bundle registers with the seam instead is the only thing the seam ever asked of an installation — the answer to its area contract. That is a deeper integration than a catalogue tile, not a shallower one: without it the seam has no schema at all. The absence of the tag is pinned by tests/Integration/CatalogueAbstentionTest.

What is here

Piece File
The Symfony plug, the mapping and the resolution src/UhifadhiAreaBundle.php
The area src/Entity/AreaOfInterest.php
Its spatial repository src/Repository/AreaOfInterestRepository.php
The zone src/Entity/Zone.php
Its spatial repository, and the two invariant predicates src/Repository/ZoneRepository.php
The only supported way a zone gets a geometry src/Service/ZoneService.php
What a broken zone invariant reads like src/Exception/ZoneOverlapException.php
Whatever polygonal GeoJSON arrived, as one MultiPolygon src/Service/GeoJsonNormalizer.php
A file becomes an area src/Service/BoundaryImport.php
What a refused import reads like src/Exception/BoundaryImportException.php
An area's composition, read from the seam's services src/Service/AreaComposition.php
A module as the shop prints it src/Model/ModuleRow.php
The overview contribution seams src/Overview/
The department-KPI seam src/Kpi/
Static service wiring — the model config/services.php
Static service wiring — the screens config/screens.php
The screens src/Controller/
Where you are, and the sidebar section src/Shell/
The area vocabulary public/area.css
The register's search, filter and sort assets/controllers/area_register_controller.js
The boundary drop zone assets/controllers/area_upload_controller.js
Dragging a module into place assets/controllers/module_order_controller.js
The templates templates/
An installation, minimally tests/Integration/TestKernel.php
The gap this module closes tests/Integration/InstallabilityTest.php
The tag literals the fleet writes by hand tests/Unit/Overview/ContributionSeamContractTest.php
The design workshop's labels do not ship tests/Unit/Template/NoWorkshopLabelsTest.php

There is no configuration tree and the recipe ships no config/packages/area.yaml. Nothing here is an installation's to set: the entity has no options, the table name is a compatibility promise rather than a preference, and the one decision anybody could want to make — "my areas are my own class" — is made by Symfony's override rule above, not by a key invented here. A config file that exists only to sit empty is a file somebody has to read.

Installation

composer require uhifadhi/area-module

Flex registers the bundle ("type": "symfony-bundle") and brings fundistadi/postgis-bundle with it, which is registered the same way. That is the whole installation: the bundle maps its own entity and answers the seam's contract, so there is no doctrine.yaml edit of any kind.

Your database needs PostGIS:

CREATE EXTENSION IF NOT EXISTS postgis;

Then the tables, in the ordinary order:

bin/console doctrine:database:create
bin/console doctrine:migrations:diff      # your history, your migration
bin/console doctrine:migrations:migrate
bin/console seam:catalogue:seed

This module ships entities, not migrations. The tables are the bundle's; the migration history is yours, and a vendor replaying its own versions into it would fight every diff you ever run.

The seed reads areas through the resolved class, which is why it works now. An installation whose interface was unresolved got a catalogue, no per-area rows, and a success message — the seed treats "no area class" as "no areas to backfill" rather than as a fatal, so the omission was silent.

Upgrading an installation that has the placeholder

An installation created before this module exists was told to write its own App\Entity\AreaOfInterestgetId() and nothing else — and to uncomment the resolution in config/packages/seam.yaml. Adopting this module needs no rename migration, and the steps below are a transcript rather than a sketch: they were walked on a create-project installation built the old way, with a row in its area_of_interest table.

1. Your database needs PostGIS, which an installation with a one-column placeholder does not have:

CREATE EXTENSION IF NOT EXISTS postgis;

2. Install the module.

composer require uhifadhi/area-module

3. Delete both halves of the old hand-step. Remove your placeholder class (src/Entity/AreaOfInterest.php) and the resolve_target_entities block you uncommented in config/packages/seam.yaml. Both have to go, and forgetting the class is the loud one — two entities claiming one table:

$ bin/console doctrine:migrations:diff
The table with name "public.area_of_interest" already exists.

Forgetting only the config block is quieter and worse: a resolution left in place still wins, naming a class you just deleted.

4. Diff. The table does not move — this module's table is area_of_interest, stated explicitly and pinned by a test, and that is exactly the table the documented placeholder produced under the skeleton's underscore naming strategy. Your rows, your foreign keys and the seam's area_module.area_id all stay where they are. Every line of the generated migration is an ADD:

ALTER TABLE area_of_interest ADD name VARCHAR(128) NOT NULL;
ALTER TABLE area_of_interest ADD geom geometry(MULTIPOLYGON,4326) NOT NULL;
ALTER TABLE area_of_interest ADD source VARCHAR(64) NOT NULL;
ALTER TABLE area_of_interest ADD iucn_category VARCHAR(8) DEFAULT NULL;
ALTER TABLE area_of_interest ADD established_year INT DEFAULT NULL;
ALTER TABLE area_of_interest ADD created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL;
ALTER TABLE area_of_interest ADD updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL;
ALTER TABLE area_of_interest ADD uuid UUID NOT NULL;
CREATE UNIQUE INDEX UNIQ_FB9E46FCD17F50A6 ON area_of_interest (uuid);
CREATE INDEX idx_area_of_interest_geom_sp ON area_of_interest USING gist (geom);

No RENAME, no DROP TABLE, and the seam's foreign key is not in the diff at all.

5. Mind the four NOT NULL columns if your table has rows. A placeholder area had nothing on it but an id, so there is no value a migration could infer, and running the generated one as-is stops:

SQLSTATE[23502]: Not null violation: 7 ERROR:
  column "name" of relation "area_of_interest" contains null values

If the table is empty, run the migration as generated. If it is not, edit it: add each of name, geom, source and uuid nullable, UPDATE the rows with the name, boundary and identifier the area actually has, then ALTER … SET NOT NULL. That is a migration only your deployment can write, because only your deployment knows where its boundary comes from.

Upgrading from 0.3 to 0.4

One column is dropped: tree_cover_pct.

It was a single ingestion-shaped module's reading persisted on the entity every other module is filed under — the monolith's coupling, carried across in the port because it was already in the table. No module in the fleet ever wrote it and nothing read it. A field nobody may use is still a field everybody has to read past, so it is gone rather than deprecated: when a module owns that figure it will contribute it the way a module already contributes an overview widget.

Run the diff and you get one line — this is the real generated statement, not a sketch:

ALTER TABLE area_of_interest DROP tree_cover_pct;
$ php bin/console doctrine:migrations:diff
$ php bin/console doctrine:migrations:migrate

If your installation actually put numbers in that column, they are the one thing this drop destroys — copy them out before migrating. Nothing in the published fleet could have written them, so for almost every installation the column is null in every row.

Nothing else moves: no table is renamed, no other column changes, and the seam's area_module.area_id foreign key is not in the diff at all.

Upgrading from 0.4 to 0.5

No migration, no configuration, no code change. v0.5.0 adds screens and adds nothing to the schema: the create flow writes the area_of_interest row that already existed, and the module shop writes the seam's area_module table through the seam's own service. composer update uhifadhi/area-module is the whole of it.

Two things change on their own, and both are the point:

  • The register's + New area button starts working. It rendered a literal /areas/new href that 404'd; it is now path('area_new'), gated on area.create, and hidden from anybody who does not hold it. An installation whose people should not create areas needs to check that area.create is not in more positions than it means.
  • A Modules tab appears above every area, and in the sidebar under it, for anybody holding module.view — because seam_area_modules now answers. Elsewhere in the fleet the same route lights the patrol module's breadcrumb and its dashboard back-button, with no change to that package.

symfony/security-csrf joins the requirements — the shop writes, so every control on it carries a token. Composer pulls it.

If you had mounted your own seam_area_modules route, yours still wins: routes are the application's, and the recipe's import is a file in your config/routes/ that you may edit or delete.

Not here yet

Stated so nobody looks for them. This module is two entities, a resolution and a set of seams; none of the following is in it, and that is a scope decision rather than an oversight.

  • No boundary import beyond GeoJSON. A shapefile, a GeoPackage, a KML or a WDPA extract is refused by name. Converting them means ogr2ogr on the host, and a reusable bundle will not make a system package the price of a first area; the path returns behind an optional collaborator. The same goes for zones: the entity and its invariant are here, a zone importer is not.
  • No live import transcript. The design draws a second card on the create screen that narrates an import step by step over Mercure. Every step it narrates belongs to the conversion path above; what is left — parse one document, insert one row — finishes in milliseconds and has nothing to watch. The card returns with the thing it describes.
  • No zone creation screen. Zones can be read and are drawn on the area's zoning lens; there is no screen for making one. The design workspace has not drawn it, and this module does not invent UI.
  • No module reordering without JavaScript. Switching a module on or off is a real form and works with scripting off; the drag ordering is not, and that is the one preference on the shop that scripting buys.
  • No map rendering. Drawing a boundary is uhifadhi/map-module's business, and this module does not depend on it. MapLayer describes what a module contributes to a plate; it does not draw one.
  • No API. Serving an area to a field application is an API surface with a contract of its own.
  • No fixtures. Making an area for a demo installation belongs to whatever seeds it.
  • No tree-cover figure, and no other module's measurement either. An area is a name, a boundary, where that boundary came from and its registry facts. A reading belongs to whatever module measures it, and reaches a page through a contribution seam — never through a column reserved on the entity everything else is filed under. Pinned by Integration\Area\AreaPersistenceTest::testTheAreaHoldsNoForeignModulesFigure, which asserts the whole property list rather than the one field that was removed.

Development

composer install
composer check   # cs:check -> phpstan (max) -> the suite
  • PHP 8.4+, PHPStan level max, php-cs-fixer @Symfony + @Symfony:risky.
  • Tests first, always. A behaviour change starts as a failing test naming the class or service id it wants; the change is the commit that makes it pass. CI gates on composer check: one suite, one verdict.
  • The suite talks to a real PostGIS database named by AREA_TEST_DATABASE_URL (see phpunit.dist.xml) and rebuilds the schema per test — a boundary column only ever asserted against a mock would prove nothing. On the fundi cluster, once:
createdb -h 127.0.0.1 -p 5434 -U app area_bundle_test
psql -h 127.0.0.1 -p 5434 -U app -d area_bundle_test -c 'CREATE EXTENSION IF NOT EXISTS postgis;'

License

AGPL-3.0-or-later — see LICENSE. Use, modify and self-host freely; if you offer a modified version to users over a network, they are entitled to the source of what they're running.