个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证正在加载完整 README。 以下为已保存的摘录。
A free, client-only parcel-research tool for City of Winnipeg properties, built
for commercial-appraisal workflows.查看完整 README ↗A free, client-only parcel-research tool for City of Winnipeg properties, built for commercial-appraisal workflows.
Live: https://winnipeg-opendata-parcelsearch.vercel.app/
Search parcels by legal description (Lot / Block / Plan) or by roll number, civic address, zoning, and dwelling-unit count; see survey lots and assessment parcels together on a MapLibre map (street, satellite, or 7.5 cm City aerial basemaps); toggle ~15 reference overlays (citywide zoning, traffic volumes, transit, OurWinnipeg policy areas, contaminated sites, neighbourhoods, parcel dimensions); browse historical as-of-date parcels with inferred lineage and size-change highlighting; analyse comparable sales from SABRE exports (drop a file, paste a block, or connect a folder as a local sales database); export results to CSV.
There is no server, database, or login. The browser queries the
City of Winnipeg Open Data Socrata API live on
every search; heavier citywide overlays are cached in IndexedDB. Historical
snapshots stream as one whole-city PMTiles archive per date from R2 (built
by r/build_historical_tiles.R from the companion
wpg-parcel-history
data repo, which still serves the lineage and as-of zoning files via CDN).
cd web
npm ci
npm run dev # http://localhost:5173 — live data on every search
npm test # plain-Node unit tests (no framework)
npm run build # production bundle in web/dist
Deploys are automatic: every push to main rebuilds on Vercel
(vercel.json); CI runs the test suite + a build on every push.
| Path | Contents |
|---|---|
web/src/ | The app — main.js (UI wiring), soda.js (all SODA queries/joins), map.js (MapLibre layers), lib/ (reusable modules) |
web/test/ | Unit tests, run by npm test |
web/scripts/ | Builders for the committed static overlays (transit GTFS, neighbourhoods) |
web/public/ | Static GeoJSON overlays. parcels.pmtiles (citywide parcel polygons z8–z18, address labels, and derived dwelling-unit totals; ~100 MB) is not in git — deploys fetch it from the parcels-pmtiles GitHub release (see vercel.json); keep a local copy for dev. Rebuilt and republished automatically every month (see Data freshness). |
r/ | Offline R/PowerShell pipeline: scheduled Open Data downloads, provenance-stamped snapshot archive, historical shard + lineage builders, citywide-parcels, per-snapshot historical, and aerial-ortho PMTiles builds, the DMIS zoning-amendment scrape |
tools/ | Standalone command-line tools, outside the web build. address_aliases.py — civic-address aliases for a property, Winnipeg or Manitoba (see Address aliases). Python stdlib only, no dependencies. |
extras/ | Early experiments kept for reference |
Below 768px the page is map-first. lib/phoneMode.js sets body.phone from
a matchMedia query and everything narrow-screen hangs off that one class:
lib/sheetDrag.js, which works in transform space so a 100-row table
inside the sheet never re-lays-out per pointer move).#results-wrap is relocated, not
duplicated, so every handle main.js captured at boot stays live; it goes
back to its exact workspace position when the viewport widens. A fresh
result set lifts a peeked sheet to half (ensureSheetVisible in
renderTable, keyed on a new row array so a sort or numbering toggle
does not).The CSS is keyed on body.phone rather than a bare media query so the JS
that moves the results and resizes the map and the CSS that lays them out
cannot disagree about which mode is on. test/phoneShell.test.js reads all
five pieces (markup, the two modules, the main.js call, the CSS) and
asserts they name the same ids, classes and breakpoint, with comments
stripped, so no piece can ship unwired. The two modules are shared verbatim
with the Manitoba app; the same test diffs them against that checkout when
it is present.
Two Winnipeg-only phone rules sit at the end of style.css: the From # /
To # fields share the row (their 80px desktop slots clipped "From #" at the
16px phone font), and the results header and its pinned first column stack
beneath the tab strip instead of painting over it as the table scrolls under.
A parcel carries many civic addresses; the assessment roll records one. Roll
07560170500 is 1347 Border Street to d4mq-wa44 and to
winnipegassessment.com, but the same lot is also 1361 / 1377 / 1381 / 1385 /
1393 Border St and 1860–1872 Notre Dame Ave. Those aliases exist in open data
only as address points in cam2-ii3u, which carries no roll number, so the
two have to be joined spatially. The app does it in both directions:
cam2-ii3u and
finds the parcel each matching point sits in, so searching 1393 Border St
returns the 1347 Border Street roll.The first entry is the assessment record's own address and the only one winnipegassessment.com can be searched by; hovering a multi-address cell says so and lists the rest.
Addresses are shown in Proper Case — "1636 McCreary Road", not the
CAPITALS both datasets publish. Display only: every key, dedupe and lookup
still runs on the raw uppercase, so casing can never decide whether two
entries are the same address. properCaseAddress in
lib/addressFormat.js keeps directionals
(AVE E), ordinals (3rd), unit and civic numbers, and the Mc streets
(McPhillips, McDermot) out of a blanket capitalize-each-word, and leaves any
word a source had already cased alone. The map's own parcel LABELS stay
uppercase — a MapLibre style expression has no case function.
tools/address_aliases.py answers the same question
from the command line, for Winnipeg and (with --mb) for the rest of Manitoba —
see tools/ADDRESS_ALIASES.md. It is a standalone
stdlib script, not part of the web build.
SoQL has two spatial predicates and they are not interchangeable:
within_box(geom, box) true when geom is entirely INSIDE box
intersects(geom, wkt) true when geom and wkt share any point
within_box cannot answer "what covers this feature". It answers "what is
small enough to fit near it", and it fails silently and in proportion to the
target's size — no error, just a short list of the neighbours that happened
to fit. Every spatial join in the app was built on it, and all four were wrong:
intersects(geometry, POINT(...)), which is exact.fetchZoningOverlap, cold-cache branch). A zoning
district is far larger than the lot it governs, so the query returned the
small neighbouring districts and dropped the governing one, leaving the
area-weighted Zoning % / Zoning 2 columns blank. Measured against the
exact answer (intersects on each parcel's own polygon): 26 of 26 Border
St parcels lost their district, as did 9 of 10 lots on Fieldstone Bay, while
Osborne St lost none — mature-area districts are small enough to fit the box,
which is why it went unnoticed. A citywide sample put it near a quarter of
parcels. Now intersects against each parcel's padded bbox rectangle: five
corners of WKT rather than a parcel outline's hundreds of vertices, giving a
superset that enrichAssessmentZoning already narrows with an exact turf
pass.fetchAssessmentOverlap), the legal-description
flow. An assessment parcel routinely consolidates several survey lots, so it
is bigger than the lot searched from. Searching Plan 2930 returned 220 rows
of which 20 had no roll number, no address and no assessed value — blank
rows in the results table. It also disabled partial-lot detection, which asks
this same question about every parcel a survey lot touches. Now 0 blank rows
on the same search.fetchSurveyOverlap), back-filling Lot / Block /
Plan. This direction looked safe because a survey lot is usually smaller than
the assessment parcel over it — except for the River Lot and Outer Two Mile
parcels the app explicitly supports, which are enormous and are exactly the
ones carrying the legal description. Containment dropped a real survey parcel
for 4 of 12 Border St rolls.fetchCurrentAssessmentInBbox), which
feeds the historical overlay's gone/retired detection. A parcel larger than
the box is not contained by it, comes back missing, and is painted grey as a
roll that no longer exists. On a bbox drawn inside the Border St warehouse
the old query returned zero rows where 19 parcels really overlap — every
one of them a false "gone". The complete guard cannot catch this: nothing
was truncated, the predicate simply excluded them. Its week-long cache key
moved to wpg_curasmt3_ so rows written by the old query aren't served past
the fix.The zoning one stayed invisible because the warm path was right.
fetchZoningOverlap prefers the citywide IndexedDB cache and filters it
in-process with a true bbox overlap test, so the columns filled in correctly
once the Zoning overlay had been toggled once — and only then. The same search
gave different answers on a first visit and a second.
within_box now appears exactly once in soda.js, in
fetchAddressPointsForParcels, where the target is an address POINT and
containment is the same question as intersection. The per-feature helpers share
one batching engine and differ only in the clause they build:
fetchPerFeatureBboxIntersects (size-blind overlap against a bbox rectangle)
and fetchPerPointIntersects (exact point-in-polygon). sodaNetwork.test.js
pins the predicate each join asks for, because a regression here reports no
error and no empty result — just quietly fewer rows.
Import list… — on the Property Search sidebar, and again beside Paste data… on Sales Analysis — takes a pasted block or a CSV/TSV of civic addresses, resolves each to an assessment parcel, and maps the lot. Roll numbers work in the same list, and the two can be mixed.
One modal serves both tabs, because the parsing and the lookup are the same job; only what happens on confirm differs.
Three things are load-bearing:
330 Selkirk Ave, Winnipeg, MB gives three cells,
of which only the first parses, so the locality cells are dropped as the
noise they are. A real CSV with the address in column 3 lands on column 3
for the same reason. Extra columns are ignored.like matches span two street names is reported ambiguous rather
than guessed at.A Sample CSV link — beside the modal's file picker, and on its own line
under the Import list button in both sidebars — downloads
sample-parcel-list.csv — nine rows
covering every shape the importer accepts: an address with the city and
province, one without, one carrying a postal code, a full 11-digit roll,
one with the leading zero Excel dropped, one written with dashes, a side
door (440 Hargrave, assessed as 400 Hargrave), and punctuation-heavy and
French street names. Its Comp and Notes columns are there to be
ignored, which demonstrates that rule while letting the file document
itself. Every row resolves against the live assessment roll, and
test/parcelListParse.test.js reads the real file rather than a copy, so
the sample cannot drift into one that no longer parses.
Parsing is only half of it. A parcel the City renumbers or re-addresses
would leave its row parsing perfectly while matching nothing — a stale
sample teaching a shape that no longer works, which no offline test can
see. npm run check:sample resolves every row against the live roll and
fails naming any that no longer land; a monthly workflow
(.github/workflows/sample-list-check.yml) runs it so that turns into a
notification rather than a user's bug report. It is deliberately NOT part
of npm test, which stays offline and must never fail because someone
else's server had a bad minute.
The last five imports are kept in localStorage and offered as a
Recent dropdown, so a comp list can be reloaded after a Clear without
finding the file again. The TEXT is cached, not just the name — a browser
keeps no standing handle to a file picked from disk, so replaying the
stored text is the only way a recent entry can actually reload anything.
Picking one goes straight to the review screen; the × forgets them all.
On Property Search the import button shares a row with the numbering pill.
The pill asks for exactly what its label and three segments need and does
not shrink; the button takes the rest, which lands it around half the row.
Sizing the two as fixed percentages did not work: the segments were being
STRETCHED to fill a 50% slot, and that — not a shortage of room — was what
left no space for the pill's label. Two labels are shortened for that row
only, via data-short and ::after, so the Sales tab's full-width copy
and the accessibility tree still read Numbering: and By roll #.
Export CSV sits at the foot of the sidebar under Generate Map — both ways of getting results out of the app in one place, rather than an output action in the row that starts a search.
Lists longer than the 500-entry SoQL IN cap are split across queries and
merged, with every other filter carried on each chunk. Before the importer
existed no UI could produce such a list; rollClause truncated at the cap
instead, which would have made the tail of a big import vanish while the
search still reported success.
Parsing lives in lib/parcelListParse.js
and resolution in lib/parcelListResolve.js.
Both are pure — the resolver takes its fetchers by injection — so the whole
decision tree is unit tested with no network.
Winnipeg publishes no sales dataset, so sales always arrive from the user: a SABRE SoldPropertyListing export dropped on the sales tab, a block pasted out of SABRE or a spreadsheet, or the SABRE sales database — a folder of exports connected once and cached locally.
That data is a paid subscription product, so it is never hosted or uploaded.
The database lives in IndexedDB (wpg-parcel-sales) behind a File System
Access directory handle, which is what lets a new export dropped into the
folder be noticed on the next visit rather than re-imported by hand. Chrome
and Edge support the handle; Firefox and Safari fall back to a manual folder
pick, which works without the auto-refresh. Firm users connect the shared
AppMarketData\SalesData\Winnipeg folder (the on-page hint says so). A
mistaken pick of AppMarketData or SalesData still works, because src/lib/appMarketData.js steps down
to Winnipeg before the handle is saved. Its coverage is shown only in the
sales panel's own Coverage dialog — never in Data Status, which is public.
Two things about the merge are load-bearing:
dedupAndGroupSales sums the DISTINCT areas, which
matches on 156 of 168. It also collapses SABRE's blank-Zoning twin rows,
which used to report 609 sales at exactly 2.00x their true floor area and
so halve every $/Bldg SF.Rates. $/Lot SF prices the dirt; $/Bldg SF prices the improvement
(Living Area from the export, else the live record — withheld on a
vacant-coded sale, since the live record describes the parcel today and
a lot that sold bare then got built on would otherwise report a
confident, fictional building rate).
The land denominator is SABRE's Land Actual sqft — the area at the time of
sale — falling back to the assessment record's assessed_land_area where
SABRE's is missing or implausibly small, and withheld entirely where neither
is usable. 41 records carry an area under 100 sf, most of them literally 1,
and they are not tiny parcels: their prices are ordinary and the roll gives
them 2,460–8,217 sf. Dividing by the placeholder printed rates up to
$615,000/sf, which left the median alone but destroyed every trend the charts
fit. A rate computed on anything other than the row's own figure is marked
with a ⚠ rather than substituted silently. Acres is derived from that same
denominator (÷ 43,560), the City publishing area in square feet only, and joins
$/Acre and $/Lot — the latter being the consideration split across
the parcels in the transaction, which is what prices a building lot in a
multi-lot deal. Every rate uses the same group-total denominator, so an
assembly is rated as one deal.
Was a building standing? The assessment use code describes a parcel as it was last assessed, not as it stood the day it sold, so a comp set built on the code alone inherits that error in both directions. Three instruments answer, strongest first, each consulted only where the one before it said nothing: a City building permit matched by address (6,238 findings), the assessment roll's own year built (34), and SABRE's own record (27) — which is the only one that can speak for a parcel whose roll has since been retired, and 71 of those 72 rolls are gone from the live assessment data too. The Built column names which instrument spoke, because a dated permit is evidence about the transaction while the other two are later snapshots read backwards.
A fourth pass holds verdicts BACK rather than making them. Where the price
works out below $50 per building square foot — the 5th percentile of ordinary
improved sales, against a $174 median — the market paid nothing for the
building, which is what a teardown is: the sale bought the lot and a
demolition bill. Those 6 sales stay in the Land set with a
⚠ Building, priced as land mark instead of being reclassified out of it. A
permit verdict is never overruled this way. And where the roll has been
retired and nothing can answer at all, the column reads not verified rather
than going blank — 73 sales, still counted as Land, but checked by nothing.
The Charts button opens charts.html in a second
tab: $/Lot SF over time, $/Acre against lot size (the
size-adjustment curve), and $/Lot against lot size. It holds no data of its own — the app broadcasts the filtered set
over a BroadcastChannel and the page redraws, so the charts track the
sidebar filters live (hence Freeze). It defaults to land sales — which now
means the grid's evidence-corrected Category, so the two can never disagree
about what land is — and to EXCLUDING already-built sales, vacant-coded lots
that in fact carried a finished house when they changed hands. Roughly half
the vacant-coded sales in the archive are those, and they clear about 3x a
genuine lot per square foot, so leaving them in dragged every land trendline
upward. The header reports the count either way round.
A chart point is a SALE, not a grid row: a three-parcel assembly is one
transaction, and three points would triple its weight in every
trendline. The renderer is hand-rolled SVG because the CSP is
script-src 'self' — no CDN chart library can load.
N1 cross-reference. Sales carry an optional N1 ID column, stamped by an
offline crosswalk rather than matched in the browser (the same division of
labour the Manitoba app uses). The grid shows it and the sales tab filters
Any / Matched / Unmatched, shareable as ?n1=. Blank is the useful state:
unmatched rows are the queue of sales still to be entered into N1. The filter
is row-level, so a multi-parcel sale matched on only some of its rolls shows
exactly the rows that still need doing. A CSV with no N1 ID column reads as
entirely unmatched, which is the truth.
The panel has two halves, and the Search button is the line between them. Everything ABOVE it decides what a search FETCHES -- the sale date range, the vacant / improved cut and the nominal-transfer toggle. Everything BELOW it re-filters what is already loaded and costs nothing: neighbourhood, category, PUCS, class, zoning and the Additional filters. A horizontal rule makes the split visible rather than something to infer from which controls happen to be slow.
Vacant / improved moved above the line because it is the cut that most changes what a search COSTS. Every sale it removes is an assessment record never requested and a row never drawn.
WHICH FILTERS CUT BEFORE THE FETCH. Sale date, PUCS, sale price, lot size,
street name, N1 and vacant / improved all narrow the sale list before a single
d4mq-wa44 request goes out. Category, neighbourhood, class, zoning, year
built, building size, far-flung and the subject radius run AFTER the join,
because each reads something that does not exist until the live record is in
hand.
Year built and building size are post-join even though the export usually carries both, and that is a deliberate choice rather than an oversight (an earlier pass cut them early and was reverted, Jason 2026-09-16). They read a dual source — the export's value when it has one, the live record otherwise — so only the join has their second half; and they are the two an appraiser TUNES, nudging a bound while watching the count. Cutting them before the fetch would put a network round trip behind every nudge, making the common case slower to save time only on the first run.
Vacant / improved does this WITHOUT changing which sales you see, which is the
part that needed care. saleUseCodeOf falls back to the live record's
property_use_code when an export row has none, so judging purely on the
export would quietly drop sales the join could have classified -- a narrowing
disguised as an optimisation. So csvGroupVacancy decides a sale group only
when EVERY member carries its own Par Use Code and returns null otherwise;
passesPreJoinVacantFilter FAILS OPEN on null, and the post-join check remains
the authority. The cut can only remove rows that check would have removed
anyway, and a test asserts the two agree on every decidable group. The count
line reports both halves together, so a run that dropped 3,000 sales before the
fetch does not report "3 hidden".
WHAT IS STILL FULL-COST. Search always merges and parses the WHOLE archive out
of IndexedDB before any filter runs -- the pre-fetch cuts save the
d4mq-wa44 round trip and the row draw, not the CSV read. On a large archive
that parse is the floor on how fast a Search can be.
Search is disabled, not hidden, until the SABRE folder is connected: the row keeps its shape and the button says why it is unavailable.
Where, not what: neighbourhood and radius. Two location filters narrow a comp set the way an appraiser actually reasons about one.
The Any neighbourhood picker lists the City's neighbourhood CLUSTERS (Fort
Garry South, Point Douglas North, Transcona ...) present in the loaded sales,
each with its count. The label says "neighbourhood" because that is what these
areas are called out loud; the code, the data and the grid's Cluster column
keep the precise word, since each of the 23 groups about ten of the City's 235
actual neighbourhoods. The CSV export header stays Cluster too -- renaming an
exported header breaks whatever downstream already reads it.
A parcel's cluster comes from a point-in-polygon of its centroid against
public/wpg-neighbourhoods.geojson, never from the assessment record's
neighbourhood_area -- that field is truncated to 20 characters, so a name join
leaves ~13,000 parcels unmatched and gains a new casualty every time the City
truncates something differently. Parcels the lookup cannot place (no centroid,
or a centroid outside every neighbourhood -- city-edge parcels genuinely exist)
collect under a named (no cluster) option, tickable like any other, so they
can never drop out of a search unseen.
TWO FALLBACKS fill that bucket in before it is shown (2026-09-16), because the 235 neighbourhoods do not tile the city: rail corridors, river lots, road allowances and the city edge all leave slivers a centroid can land in.
cam2-ii3u). One request per STREET rather than per sale, with the
house numbers matched off the front of full_address, and capped at 40
streets a run: this is a nicety, not the analysis.What neither fallback can place lands in one of TWO named, tickable buckets, split apart on 2026-09-16 because one label was answering two different questions and telling them apart meant inspecting the rows:
(outside Winnipeg) -- a real coordinate further from every
neighbourhood than the cap. In practice an MLS row for an out-of-town
property, so bad data identifies itself on import.(no cluster) -- nothing to place it with at all.Neither is auto-assigned to the nearest Winnipeg cluster, and that is what the cap is FOR: an out-of-town comp wearing a local label reads as a local comp, and nothing on the row would contradict it. Neither fallback marks its result as inferred -- Jason's call; a placement is a placement.
The boundaries are ALWAYS DRAWN on the Sales Analysis tab, the way the Manitoba app always draws its municipality boundaries on its own Sales tab, and they are a control rather than decoration: everything starts selected, and clicking a neighbourhood switches it OFF -- its sales leave the grid and its outline and name fade back. Clicking it again brings it back. That is exactly what unticking and re-ticking its box in the picker does, deliberately: the map and the popover are one control with one state, so neither can show a picture the other would read differently.
Because it is always on, the layer's resting state is a whisper -- a slate hairline, a barely-there fill and small grey names. There are THREE weights, not two: at rest (nothing filtering) every boundary sits at that hairline; once a filter is actually narrowing, the neighbourhoods still IN it step up slightly so what is in play reads at a glance, while the excluded ones go fainter still rather than disappearing, which would read as a rendering failure. Resting and stepped-up are deliberately close -- the point is to notice the difference while scanning the city, not to make the backdrop the loudest thing on the map again. Hover is the only assertive state, so the cursor has feedback. The fill never carries the state strongly: a cluster is a big polygon with the sales and parcels being read inside it, and a tint strong enough to notice is a tint strong enough to get in the way.
The neighbourhood name that follows the cursor stands down whenever a parcel is drawn under it, so it never tags along beside the parcel popup you are actually reading.
Clicking the last selected neighbourhood off leaves NOTHING selected and therefore no results, the same state unticking every box reaches, and the count line says so and names the way back. Selecting every one on offer collapses to "Any neighbourhood", which is invisible because all-selected and no-filter show the same rows.
The picker is armed only on the Sales tab and only for a neighbourhood the
loaded sales actually reach. On the Property tab, and for one with no sales in
the set, a click still opens the layer's ordinary info popup naming the cluster
and its neighbourhoods -- an honest answer beats a silent no-op that reads as a
broken control. The cursor says which you will get (pointer vs help). A
click also stands down wherever a parcel or sale is drawn on top, since these
boundaries are the lowest vector layer on the map and a click aimed at a sale
must not re-filter the whole comp set behind it.
Visibility has two owners that cooperate rather than overwrite: the Map Layers Neighbourhoods button (Off / Clusters / Neighbourhoods, both levels, everywhere) and the Sales tab's always-on backdrop. Cycling the button to Off while on the Sales tab therefore leaves the boundaries up -- hiding the thing you are meant to be clicking would be the bug. Both paths load through one function, so the 23 names the fade needs are filled in whichever way the data arrives. The 235 individual neighbourhoods stay entirely the button's business; they are reference, not a control.
The radius field beside the subject roll keeps only sales within N km of
the subject, measured centroid to centroid -- the same figure the Dist (km)
column shows, so the filter and the column can never disagree about how far
away a comp is. Decimals work; blank or 0 is off; the boundary is inclusive.
With no subject roll set the radius is IGNORED rather than applied, and says
so beside the input and on the count line: every row would otherwise fail for
want of a distance and the empty grid would read as "no comps nearby" when
what happened is that nothing said where "nearby" was. A sale whose roll found
no live record has no centroid and is excluded while the radius is set --
missing is excluded, the same rule the size and street filters follow.
Shareable as ?rad=, and both filters report what they removed on the count
line above the table.
Both are row-level, like Category, Class and Zoning and unlike the group properties (lot size, vacancy, far-flung). Where a parcel sits is a fact about the PARCEL, not about the transaction, and it is what the Cluster and Dist cells on that row show -- so filtering row-level keeps the filter and the grid saying the same thing. A multi-parcel sale straddling a cluster boundary can show some of its rows and not others; judging it per group would instead leave rows on screen that visibly contradict the filter that kept them.
PUCS names. The PUCS picker lists each Par Use Code with the City's own
name for it (RESSD / Detached Single Dwelling), from the same table
lib/pucs.js feeds the Use column. 57 bare five-letter codes is not a list
anyone can choose from, and the appraiser is looking for a use, not a
mnemonic. Codes the table has never seen render bare rather than vanishing.
PUCS counts. The number beside each code is what picking it would actually get you: it is tallied from the sale list every OTHER filter has already left, including Category and Neighbourhood (Jason, 2026-09-16). Those two are post-join -- a sale's category is only final after the permit pass and its cluster after the centroid lookup -- so their verdicts are cached per sale as each search completes and read back when the counts are built. A sale no search has fetched yet has no verdict and is counted, which makes the numbers an upper bound on a cold cache and exact once the archive has been searched once. Every code stays on the list at zero rather than vanishing, so a count can never take away an option you need in order to tick it back.
Year built and building size. Two range boxes above the Charts button,
outside the Additional-filters disclosure because they are everyday cuts rather
than tune-later ones. Both read the SAME dual source their columns do -- the
export's value when it has one, the live assessment record otherwise -- so a row
visibly showing 1962 can never go missing from a 1950-1970 search. A
multi-section sale is tested on its OLDEST year, the figure lib/sales.js
already computes for the Year Built column's sort. Missing is excluded once
either is set, which for building size includes every vacant lot; the count line
names how many each removed. They are deliberately NOT in the disclosure's
warning badge: that badge exists to flag filters the collapsed disclosure
HIDES, and these are in plain sight.
Sales on the map: dots at city zoom, parcels up close. At a whole-city view a lot is sub-pixel, so the sales also publish as POINTS and draw as circles that do not shrink with the parcel. The circles fade out over z13-15 exactly as the polygons become large enough to read, so there is one symbol per sale at any zoom.
Both carry the same colour, by appraisal CATEGORY, and the assignment follows whatever the search left rather than a fixed scheme. It is STICKY: a category keeps its colour for as long as it is on screen. That is the rule that makes the map readable -- if ticking one more category re-dealt the others, every colour would mean something different from one click to the next.
FIVE colours plus a neutral grey, and the number is measured rather than
chosen. A map is an all-pairs case (any two categories can sit side by side),
so every pair must separate, and every colour must also stay inside a
lightness band and above a chroma floor. Run through the dataviz validator at
--pairs all: thirteen colours fails at normal-vision deltaE 7.1 (red vs
orange), six fails at 14.4 (brown vs orange), five passes at 18+ with the
worst CVD pair at 7.7 -- legal because the hover names the category and the
exact Par Use Code, and the legend lists every colour in use. Green is absent
on purpose: every failing pair at six involved it, and dropping it is what
bought the fifth slot. Past five, categories share the grey and the legend
says which. The polygon fill falls back to the Manitoba yellow wherever there
is no category, so a Property Search result is untouched.
What the parcel popup says. Hover or click a parcel and the popup carries,
beyond the roll / address / use / zoning / size block: Year Built and
Living Area off the assessment record, on both tabs; and for a parcel with a
loaded sale, its Sale Date, Sale Price and the rate that suits it --
$/Bldg SF for anything improved, $/Lot SF and $/Acre when the
corrected Category is Land. Hover and click share one builder
(combinedPopupHtml), so the two can never drift apart.
Copy Roll / GPS Coordinates. The CLICK popup ends in two copy links, on
both tabs and on a citywide parcel outside the search: Copy Roll puts the
roll number on the clipboard (for SABRE, N1, or the City's own search) and
GPS Coordinates puts the parcel centroid there as lat, lng. Both flip to
"Copied!" for a moment to confirm. A click over two different rolls draws one
labelled link per roll rather than picking a winner silently -- a copied value
is invisible until it is pasted somewhere else, so a wrong one is not noticed
until it matters. The HOVER popup carries neither, a deliberate divergence from
Manitoba (which emits the markup in both and wires only the sticky one): the
pointer can leave the canvas and enter the hover popup here, and an unwired
href="#" is a link that scrolls the page instead of copying. wireCopyAnchor
falls back to execCommand both when the Clipboard API is missing and when it
REJECTS -- an embedded webview can report clipboard-write as denied even on
localhost.
Every figure is READ from what lib/sales.js already computed for the columns of
the same name, never recomputed. That matters more than it looks: the group-sum
rules behind those numbers (multi-parcel land totals, the vacant-group guard on
$/Bldg SF, the assembly corrections) exist in one place, and a popup doing its
own division would be a second implementation destined to disagree. It also
means a figure the grid withholds -- a mixed sale's land rates, say -- is
withheld here for free. The rules are unit-tested in
lib/salePopupLines.js / test/salePopupLines.test.js, which is also why they
live outside map.js: that file pulls in maplibre, turf and mapbox-gl-draw and
cannot be loaded under node.
Rise (storey band). Neither SABRE nor the assessment roll says how
tall an apartment or office building is — building_type is only filled
for houses and number_floors_condo only for condo suites — so the
Rise column and the Rise filter come from an offline lookup,
public/rise-lookup.json, built by WpgOpenData/RESAPStoreys. That
pipeline derives storeys for every RESAP/RESAM and CMOFF/CMOMC/CMOGV/CMFBK
parcel in the roll from OpenStreetMap building:levels, Overture Maps
building heights, and a model on assessed value, land area, units and
zoning where neither exists; the cell tooltip names the storey count and
which of those spoke. Apartments band at 1–3 (low-rise / garden) vs 4+;
offices at 1–4 / 5–9 / 10+. Because the lookup covers parcels rather than
sales, a sale in a new export classifies as soon as it is imported. The Rise FILTER was
retired on 2026-09-16 (with its ?rise= param); the COLUMN and the lookup
behind it stay. Rebuild the lookup after a new parcel snapshot:
python RESAPStoreys/build_resap_storeys.py --group apartment && python RESAPStoreys/build_resap_storeys.py --group office && python RESAPStoreys/build_rise_lookup.py
The Data Status dialog in the top bar is the in-app view of everything below: published artifact vintages, the historical archive and its pinned CDN revision, the aerial-ortho years, and each Socrata dataset's own last-update stamp (fetched when the dialog opens, filled in per service so one dead endpoint reads "unavailable" rather than blocking the rest). A banner appears under the top bar when the citywide parcel tiles pass 60 days — two missed monthly rebuilds — and turns red past a year. It is the one alarm that fires from the deployed app rather than the scheduler machine, so it still works when the machine that runs the jobs is off.
Search results are always live — every search queries the Socrata API
directly. The build-time artifacts below are the parts that can age, so each
has a schedule and an alarm. All three jobs are registered by
r/setup_schedule.ps1, which must be run from an elevated prompt: it
registers them with an S4U principal so they run whether or not anyone is
logged on. schtasks alone can only create an Interactive task, and an
Interactive task does not run at a logon screen — it does not fail, it waits,
and no alert is possible because the job that would send it never starts. Run
unelevated, the registrar silently leaves them Interactive; it reads the
principal back and says loudly which one it got.
| Job | When | Rebuilds | Stores history? |
|---|---|---|---|
WpgParcelTilesMonthly | 2nd of each month, 03:00 | parcels.pmtiles — the Show All Parcels / Dwelling Units overlays. Fetches d4mq-wa44 live, tiles via WSL tippecanoe, publishes the release asset, commits the checksum, auto-deploys. Then wpg-survey-parcels.pmtiles (All Survey Parcels), on even months the historical per-snapshot archives, and the District Planners / Transit Walkshed GeoJSON from the legacy Property Map (planner names change). | No |
WpgOpenDataSemiAnnualDownload | Jun 1 + Dec 1, 03:00 | Downloads the r/wpg_datasets.R layers into the WpgSnapshots archive. | Yes — the only job that does |
WpgAssetRefreshQuarterly | Jan/Apr/Jul/Oct 1, 03:30 | Transit + neighbourhood GeoJSON, and runs both staleness heartbeats. | No |
Tiles refresh on their own faster cadence than the snapshot archive on purpose: the overlay should track the current roll, while history stays sparse and deliberate.
Storage stays flat. The tile jobs overwrite their archives in place
(R2, the GitHub release, which keeps one parcels-previous-* copy, and the
single _pmtiles_rollback copy). They also delete their GeoJSON scratch.
Once an upload is verified on R2, the survey and historical builders delete
their local archive copies; pass --keep-local for local dev. The monthly
job trims _download_logs to a year. Snapshot retention runs after each
semi-annual download (r/prune_snapshots.R --apply). It keeps about one
capture per 6 months, and never deletes the newest capture of a dataset or
any file a published wpg-parcel-history manifest names as its source.
The archive spans z8–z18, and the floor is measured against the camera,
not guessed from the parcels. It was z13 on the reasoning that a city lot is
sub-pixel below that — true, and beside the point. The map opens at zoom 11
and "reset view" returns to zoom 11, so at the extent every session starts in
there were no tiles to draw: toggling All Assessment Parcels relabelled
the button to "Hide All Assessment Parcels" and rendered nothing at all.
Search results were affected too — fitBounds lands below z13 for 133 of the
4,239 street names in d4mq-wa44 on a 900×520 map pane (318 on a small one),
including MAIN, PORTAGE, PEMBINA, ST MARY'S, HENDERSON and GRANT. The
whole-city extent fits at z8.33–z10.10 depending on pane, which is where the
floor comes from. r/test_tippecanoe.R asserts the floor still covers the
app's opening zoom and cross-checks that zoom against map.js, so the two
cannot drift apart again. Re-measure before raising it.
Three independent alarms cover a job that stops running, since a failed job emails for itself but a job that never starts cannot:
Each job alerts on failure over two channels — email, and an ntfy.sh
push to its own topic (wpgps-parcel-tiles-jks,
wpgps-asset-refresh-jks, wpgps-semiannual-download-jks) — and drops a
dated FAILED-*.txt marker at the archive root. Two channels because one is
one silent failure away from none: the email rides on a Gmail app password,
and the day that is revoked a single-channel setup goes quiet exactly when it
matters. Every send logs an ALERT CHANNELS: line recording what each
channel did.
The -jks topic suffix is load-bearing, not decoration. ntfy's anonymous
publish answers HTTP 200 for any topic string, subscribed or not, so a
plausible-looking variant reports success forever and reaches nobody. These
are the exact strings to subscribe to in the ntfy app. Prove the wiring
rather than reading it:
powershell -ExecutionPolicy Bypass -File r
ebuild_tiles.ps1 -TestAlert
Each of the three jobs carries the same switch; it sends one alert and exits
without touching anything else.
- The quarterly job checks all three tasks are still `LogonType=S4U` and alerts
if one has drifted back to Interactive — which is what re-running the
registrar unelevated does. Its honest limit: it runs inside one of those same
tasks, so it catches drift, not a total outage.
- The quarterly job verifies the archive actually contains the most recent
scheduled capture (Jun 1 / Dec 1, plus 21 days of grace) and that the
published tiles are under 45 days old, emailing and writing a
`STALE-*.txt` marker when either trips. It checks against the schedule
rather than a fixed age so an off-cycle capture can't shrink the margin
below one missed run.
- The quarterly job also checks the release is **servable** — that the asset
exists, is finalised, and its SHA-256 equals the committed checksum. That
is the only property the deploy actually tests, and it is independent of
the age checks: on 2026-08-05 a failed publish emptied the release while
the committed sidecar stayed perfectly fresh.
- The deployed app itself warns in the browser console when the tile sidecar
is over 60 days old — the only signal that survives the scheduler machine
being off entirely.
- The quarterly job also cross-checks the aerial-ortho years the app offers
(`ORTHO_YEARS` in `web/src/map.js`) against the archives actually on R2, in
both directions: a listed year with no archive renders a blank basemap with
no error, and an archive nobody listed is 14–18 GB that no one can see.
- The tile rebuild emails when the City publishes a residential property-use
code nobody has classified. Every `CN*`/`RES*` code must appear in either
the counted set or `DWELLING_REVIEWED_EXCLUSIONS` in
[lib_dwelling_units.R](r/lib_dwelling_units.R); anything in neither is
reported, because an unclassified code drops its parcels from dwelling-unit
totals with no visible symptom.
To rebuild and publish tiles by hand, **from the repository root** (the path
is relative; the script itself is working-directory independent, so a full
path works from anywhere):
```bash
powershell -ExecutionPolicy Bypass -File r\rebuild_tiles.ps1
Publishing never deletes the live release asset before its replacement is
uploaded and digest-verified: the new archive goes up under a staging name,
and the swap is two metadata renames (lib_gh.ps1). Do not
publish by hand with gh release upload --clobber — it deletes first, so a
failed upload leaves the release with no asset and every deploy silently
loses the overlay. r/test_gh_publish.ps1 exercises the real upload, verify,
and swap against scratch asset names on the live release.
Reviewed 2026-08-24, after the Manitoba sister project moved its equivalent archive to Cloudflare R2. The answer here is different, and the numbers are why. Re-measure before revisiting; do not port the Manitoba decision across.
scripts/fetch-pmtiles.mjs pulls the archive into web/public/ during the
Vercel build, Vite copies public/ into dist/, so the whole file ships in
every deploy. That sounds like the thing to fix. Measured, it mostly is not:
| Vercel (today) | R2 public domain | |
|---|---|---|
| Range request | 206, Accept-Ranges: bytes | 206, Accept-Ranges: bytes |
| Warm 16 KB read | 193 ms, X-Vercel-Cache: HIT | 368 ms, no Cache-Control at all |
| Egress | billed | free |
And egress barely exists: a full session — cold load at z11, toggle the
overlay on, jump to downtown at z15 — pulled 80 KB in 7 range requests
from the 99 MB archive. The file is big; nobody downloads it. R2's zero-egress
advantage is worth approximately nothing at this scale, and the public
r2.dev domain is measurably slower and sets no cache policy.
Manitoba's case was not this case. That archive is 509 MB of province-wide parcels, past what belongs in a deploy pipeline at all. Winnipeg's is 99 MB of one city.
Moving would also cost something Manitoba never had: the SHA-256 of the
published archive is pinned in web/scripts/parcels.pmtiles.sha256, committed
to git, and verified before the bytes are written — so a swapped or tampered
release asset cannot reach users. Serving straight from a bucket drops that
check unless it is rebuilt, and it would mean rebuilding the publish path that
the 2026-08-05 incident hardened.
What would change the answer: archive size, and it has started moving.
Lowering the tile zoom floor to z8 took the archive from 99.4 MB to
116.5 MB. That is still comfortably inside the 60–150 MB sanity band in
r/build_parcel_tiles.R, and a GitHub release asset may be up to 2 GB, so
nothing is at a limit — but the headroom is no longer generous, and the next
thing that adds a zoom level or a tile property spends it. The pinch point
when it arrives will be the GitHub publish, not Vercel, and R2 is where the
archive of record should then live.
Re-run the two measurements above at that point rather than assuming they still hold: the session-egress number in particular is what makes Vercel the cheaper option today, and it is a property of how people use the overlay, not of the file.
If it ever moves: the wpg-ortho bucket is already reachable (the five aerial
archives live there), its origin is already in connect-src in
vercel.json, and its CORS policy already exposes
content-range, which PMTiles cannot work without. One gap to fix first,
which already affects the aerial basemap: AllowedOrigins covers production
and http://localhost:5173 only, so branch previews are not allowed and
the aerial basemap silently fails on them. Add
https://*-jks-consulting-inc.vercel.app in the Cloudflare dashboard under
R2 → wpg-ortho → Settings → CORS Policy. Note also that
http://127.0.0.1:5173 is a different origin to CORS and is not allowed —
start the dev server on localhost.
.claude/, which is gitignored, so those are local-only history rather
than part of the repo.Parcel, zoning, address, traffic, and policy-area data: City of Winnipeg Open
Data Portal, under the
Open Government Licence – Winnipeg.
Transit overlays are derived from the Winnipeg Transit GTFS feed.
Three overlays are not on Open Data and come from the City map API behind the
PP&D Property Map
(© City of Winnipeg): Transit Walkshed (Winnipeg Zoning Schedule AC Map 2)
and District Planners by web/scripts/build-legacy-map-geojson.mjs, rebuilt
monthly with the parcel tiles (the planner names change as staff move),
and, rebuilt quarterly, the Infill Guideline Area 1 / 2 split (WMS-only there) traced from the
raster by web/scripts/build-infill-areas.py under the OSGeo4W Python, so it
is approximate to about one metre.
Environmentally tracked sites: Manitoba Contaminated/Impacted Sites Registry.
Streets basemap © OpenStreetMap contributors, via Protomaps; satellite imagery © Esri and partners; aerial ortho imagery © City of Winnipeg (Open Government Licence – Winnipeg).
This is a research aid, not an authoritative record. Parcel boundaries, dimensions, zoning, and historical lineage are derived from open data (and, for historical layers, simplified display geometry + geometric inference) — verify against the registered plan, certificate of title, and the zoning by-law before relying on them.
MIT © Jason Schellenberg