Cadets Org Chart - Proof & Build Guide
Replacing the manually-maintained Visio branch org chart (108 posts, 10 departments, 5 vacancies) with a Microsoft-only, SharePoint-list-driven Power Apps chart. Current stage: proving a Power-Fx-generated SVG in an Image control can actually reproduce the approved mockup design, before any SharePoint schema or editing screens are built. Nothing below has been run in Power Apps yet — tick each phase off only once you've genuinely run it and it matches.
What This Page Is
Working proof log for the Cadets Org Chart canvas app (local project: C:\Users\Phaderon\Projects\PowerApps-Apps\cadets-org-chart — no separate GitHub/screen-YAML repo yet, this project hasn't reached real screens). Full brief, the four-page mockup PDF and the separate interactive HTML concept live in that project's docs/handoff/ folder.
Print()/A3 export — this is genuinely new ground for this app family, not a known-working pattern being reapplied. Phase 0 below is the cheapest possible test of the riskiest assumption.
Visual Spec (measured, not eyeballed)
Extracted directly from the mockup PDF's vector paths and text runs (PyMuPDF), not read off the rendered image. Page size on both mockup styles: 1190.55×841.89pt = 420×297mm = A3 landscape. Content margin 28pt each side, 10pt gutter between panels.
Category colour palette (both display modes share this)
| Category | Hex |
|---|---|
| Regular | #E4EFD9 |
| Civil Servant | #CFF4F1 |
| FTRS | #FFC7C7 |
| Aspire | #FFE6A1 |
| ACCT UK Employee | #F6D0F1 |
| RFCA | #F7EFAC |
| ACCT UK Contractor | #D8E3FB |
| Ext Contractor | #F7D6AD |
| CFAV | #E5D4EF |
Two display modes, same palette
Mode A - "Department panels": card body fill = the category colour directly, neutral grey-green border regardless of category.
Mode B - "Quiet print style" (print priority): card body stays white; category colour becomes a 4pt-wide vertical rail on the card's left edge only. Vacant cards use a dashed grey border and pale grey fill (#F0F1F0) in both modes — vacancy overrides category colour entirely.
Cards, connectors, type
| Element | Measurement |
|---|---|
| Standard post card | 109.8 × 27pt |
| "Head of {Dept}" card | 115 × 34pt |
| Card border | 0.6pt stroke, ≈#CBD5D1, small rounded corners (~3pt, not numerically confirmed) |
| Vacant card border | 0.6pt dashed [2 2], ≈#6B7570 |
| Connector lines | 0.6pt solid, ≈#8A9A94, orthogonal only (no diagonals/curves) |
| Department header bar | fill #003E32, 22-23pt tall, white bold text |
| Card name / role text | Helvetica-Bold / Helvetica, 8pt, #172A27 |
Phase 0 - Static SVG Sanity Test
Does Power Apps' Image control accept a computed SVG data URI at all, in your actual MOD tenant? Neither this Bible nor Policy Tracker's has ever tried it — everything downstream depends on this primitive working. Test A is hand-typed, no Power Fx string-building. Test B does the same thing via a formula, to separate "does SVG render" from "does string concatenation survive."
- New blank canvas app (tablet, any format — throwaway, delete after). Insert → Image, resize to roughly fill the screen.
- Set the Image control's
Imageproperty to this complete formula (note the pre-escaped%23in place of literal#— this test also proves whether that matters):
="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 120'><rect x='0' y='0' width='300' height='120' fill='%23F7F4F6'/><rect x='10' y='10' width='120' height='34' rx='3' fill='%23CFF4F1' stroke='%23CBD5D1' stroke-width='0.6'/><text x='16' y='24' font-family='Arial' font-weight='bold' font-size='8' fill='%23172A27'>B. Hamilton</text><text x='16' y='34' font-family='Arial' font-size='8' fill='%23172A27'>Casework lead</text><rect x='10' y='55' width='120' height='34' rx='3' fill='none' stroke='%236B7570' stroke-width='0.6' stroke-dasharray='2,2'/><text x='16' y='69' font-family='Arial' font-weight='bold' font-size='8' fill='%23172A27'>GAPPED</text><text x='16' y='79' font-family='Arial' font-size='8' fill='%23172A27'>Caseworker</text><line x1='70' y1='44' x2='70' y2='55' stroke='%238A9A94' stroke-width='0.6'/></svg>"
Then, separately, add a Button whose OnSelect builds the same picture through Power Fx string concatenation instead of a hand-typed literal, and point the Image control at the resulting variable:
Set(varTestSvg,
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 120'>" &
"<rect x='0' y='0' width='300' height='120' fill='#F7F4F6'/>" &
"<rect x='10' y='10' width='120' height='34' rx='3' fill='#CFF4F1' stroke='#CBD5D1' stroke-width='0.6'/>" &
"<text x='16' y='24' font-family='Arial' font-weight='bold' font-size='8' fill='#172A27'>" & "B. Hamilton" & "</text>" &
"<text x='16' y='34' font-family='Arial' font-size='8' fill='#172A27'>" & "Casework lead" & "</text>" &
"</svg>"
);
Set(varTestDataUri, "data:image/svg+xml;base64," & Base64(varTestSvg))
Set that Image control's Image property to =varTestDataUri, and add a Label showing =Len(varTestSvg) so the generated string isn't silently empty.
Base64() exist and work as expected (Test B, unverified in the Bible)? Screenshot either way — a blank/broken image is just as useful a result as a working one. Passing this proves the primitive only — nothing about 108 posts, print, or real label lengths.
Phase 1 - Formula-Generated Command Proof (5 posts, both colour modes)
Dataset matches the same 5-post Command excerpt already validated conceptually in the handoff's interactive HTML concept (Chris Williams/COS as the head, one vacant XO with a subordinate) — not a newly invented example. Positions are hand-placed for this fixed dataset, not yet computed by a layout algorithm — that's Phase 2 and step 3 of the wider plan, not this phase.
- Add to App.OnStart:
ClearCollect(colCategoryColours,
{Category: "Regular", Hex: "#E4EFD9"},
{Category: "Civil Servant", Hex: "#CFF4F1"},
{Category: "FTRS", Hex: "#FFC7C7"},
{Category: "Aspire", Hex: "#FFE6A1"},
{Category: "ACCT UK Employee", Hex: "#F6D0F1"},
{Category: "RFCA", Hex: "#F7EFAC"},
{Category: "ACCT UK Contractor", Hex: "#D8E3FB"},
{Category: "Ext Contractor", Hex: "#F7D6AD"},
{Category: "CFAV", Hex: "#E5D4EF"}
);
ClearCollect(colCommandPosts,
{PostID: "CMD-001", X: 170, Y: 10, W: 140, H: 34, Title: "COS", Occupant: "Chris Williams", Category: "Regular", IsVacant: false},
{PostID: "CMD-002", X: 10, Y: 70, W: 140, H: 34, Title: "SO2 Information Mgmt",Occupant: "Jordan Patel", Category: "Civil Servant", IsVacant: false},
{PostID: "CMD-003", X: 170, Y: 70, W: 140, H: 34, Title: "XO", Occupant: "", Category: "", IsVacant: true},
{PostID: "CMD-004", X: 330, Y: 70, W: 140, H: 34, Title: "SO2 Plans", Occupant: "Alex Morgan", Category: "FTRS", IsVacant: false},
{PostID: "CMD-005", X: 170, Y: 130, W: 140, H: 34, Title: "Branch Administrator",Occupant: "Robin Bennett", Category: "Aspire", IsVacant: false}
);
ClearCollect(colCommandConnectors,
{X1: 240, Y1: 44, X2: 80, Y2: 70}, // COS -> SO2 Info Mgmt
{X1: 240, Y1: 44, X2: 240, Y2: 70}, // COS -> XO (vacant)
{X1: 240, Y1: 44, X2: 400, Y2: 70}, // COS -> SO2 Plans
{X1: 240, Y1: 104,X2: 240, Y2: 130} // XO -> Branch Administrator (vacancy keeps its child)
);
Set(varChartMode, "Quiet") // "Quiet" = white card + colour rail (page 4). "Coloured" = filled card (page 3)
- Add a Button
btnRenderwith thisOnSelect— the actual renderer:
Set(varCommandSvg,
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 480 180' font-family='Arial'>" &
"<rect x='0' y='0' width='480' height='180' fill='#FFFFFF'/>" &
Concat(
colCommandConnectors,
"<line x1='" & X1 & "' y1='" & Y1 & "' x2='" & X2 & "' y2='" & Y2 & "' stroke='#8A9A94' stroke-width='0.6'/>",
""
) &
Concat(
colCommandPosts,
With(
{
_cat: LookUp(colCategoryColours, Category = ThisRecord.Category),
_safeTitle: Substitute(Substitute(Substitute(ThisRecord.Title, "&", "&"), "<", "<"), ">", ">"),
_safeOcc: Substitute(Substitute(Substitute(ThisRecord.Occupant, "&", "&"), "<", "<"), ">", ">")
},
"<rect x='" & X & "' y='" & Y & "' width='" & W & "' height='" & H & "' rx='3' " &
"fill='" & If(IsVacant, "#F0F1F0", If(varChartMode = "Quiet", "#FFFFFF", Coalesce(_cat.Hex, "#FFFFFF"))) & "' " &
"stroke='" & If(IsVacant, "#6B7570", "#CBD5D1") & "' stroke-width='0.6'" &
If(IsVacant, " stroke-dasharray='2,2'", "") &
"/>" &
If(varChartMode = "Quiet" && !IsVacant,
"<rect x='" & X & "' y='" & Y & "' width='4' height='" & H & "' fill='" & Coalesce(_cat.Hex, "#FFFFFF") & "'/>",
""
) &
"<text x='" & (X + 8) & "' y='" & (Y + 14) & "' font-weight='bold' font-size='8' fill='#172A27'>" &
If(IsVacant, "GAPPED", _safeOcc) & "</text>" &
"<text x='" & (X + 8) & "' y='" & (Y + 25) & "' font-size='8' fill='#172A27'>" & _safeTitle & "</text>"
),
""
) &
"</svg>"
);
Set(varCommandDataUri, "data:image/svg+xml;base64," & Base64(varCommandSvg))
- Set the Image control's
Imageproperty to=varCommandDataUri. Add a second button togglingSet(varChartMode, If(varChartMode="Quiet","Coloured","Quiet"))then re-running the render, so both mockup styles can be flipped on the same screen.
Concat over a table with a per-row With(...) block work in this property context at all? Does LookUp inside that With correctly resolve per-row, or collapse to one value for every row (the Bible documents this exact failure shape for nested-Filter row-identifier bleed elsewhere — check the rendered output doesn't show the same category colour on every card)?
Phase 2 - Full Department (Information Systems, 24 posts, computed positions)
A tiny 5-box demo doesn't validate the real 108-post requirement. This phase does two new things: a full real department size/shape (transcribed from the mockup's Information Systems panel — same illustrative names as the source, not real staff), and positions computed from a Col/Row index rather than typed per box — a first slice of deterministic placement (the full page-grid across all 10 departments is still a later step, see Next Steps).
- Add to App.OnStart, alongside Phase 1's
colCategoryColours:
ClearCollect(colInfoSysPosts,
{PostID: "IS-HEAD", Col: 0, Row: 0, Title: "Head of Digital", Occupant: "Finley Edwards", Category: "ACCT UK Employee", IsVacant: false},
{PostID: "IS-101", Col: 1, Row: 0, Title: "Delivery lead", Occupant: "R. Phillips", Category: "ACCT UK Employee", IsVacant: false},
{PostID: "IS-102", Col: 1, Row: 1, Title: "Digital services officer", Occupant: "F. Edwards", Category: "ACCT UK Employee", IsVacant: false},
{PostID: "IS-103", Col: 1, Row: 2, Title: "Digital services officer", Occupant: "A. Morgan", Category: "ACCT UK Employee", IsVacant: false},
{PostID: "IS-104", Col: 1, Row: 3, Title: "Digital services officer", Occupant: "S. Taylor", Category: "ACCT UK Employee", IsVacant: false},
{PostID: "IS-105", Col: 1, Row: 4, Title: "Digital services officer", Occupant: "J. Patel", Category: "ACCT UK Employee", IsVacant: false},
{PostID: "IS-106", Col: 1, Row: 5, Title: "Digital services officer", Occupant: "C. Williams", Category: "ACCT UK Employee", IsVacant: false},
{PostID: "IS-107", Col: 1, Row: 6, Title: "Digital services officer", Occupant: "J. Clarke", Category: "ACCT UK Employee", IsVacant: false},
{PostID: "IS-108", Col: 1, Row: 7, Title: "Digital services officer", Occupant: "R. Bennett", Category: "ACCT UK Employee", IsVacant: false},
{PostID: "IS-201", Col: 2, Row: 0, Title: "Delivery lead", Occupant: "T. Evans", Category: "ACCT UK Employee", IsVacant: false},
{PostID: "IS-202", Col: 2, Row: 1, Title: "Digital services officer", Occupant: "", Category: "", IsVacant: true},
{PostID: "IS-203", Col: 2, Row: 2, Title: "Digital services officer", Occupant: "M. Lewis", Category: "ACCT UK Employee", IsVacant: false},
{PostID: "IS-204", Col: 2, Row: 3, Title: "Digital services officer", Occupant: "D. Campbell", Category: "ACCT UK Employee", IsVacant: false},
{PostID: "IS-205", Col: 2, Row: 4, Title: "Digital services officer", Occupant: "A. Thompson", Category: "ACCT UK Employee", IsVacant: false},
{PostID: "IS-206", Col: 2, Row: 5, Title: "Digital services officer", Occupant: "L. Richardson", Category: "ACCT UK Employee", IsVacant: false},
{PostID: "IS-207", Col: 2, Row: 6, Title: "Digital services officer", Occupant: "C. Wilson", Category: "ACCT UK Employee", IsVacant: false},
{PostID: "IS-208", Col: 2, Row: 7, Title: "Digital services officer", Occupant: "C. Stewart", Category: "ACCT UK Employee", IsVacant: false},
{PostID: "IS-301", Col: 3, Row: 0, Title: "Delivery lead", Occupant: "F. Brooks", Category: "ACCT UK Employee", IsVacant: false},
{PostID: "IS-302", Col: 3, Row: 1, Title: "Digital services officer", Occupant: "R. Mitchell", Category: "ACCT UK Employee", IsVacant: false},
{PostID: "IS-303", Col: 3, Row: 2, Title: "Digital services officer", Occupant: "E. Anderson", Category: "ACCT UK Employee", IsVacant: false},
{PostID: "IS-304", Col: 3, Row: 3, Title: "Digital services officer", Occupant: "B. Hamilton", Category: "ACCT UK Employee", IsVacant: false},
{PostID: "IS-305", Col: 3, Row: 4, Title: "Digital services officer", Occupant: "H. Foster", Category: "ACCT UK Employee", IsVacant: false},
{PostID: "IS-306", Col: 3, Row: 5, Title: "Digital services officer", Occupant: "Q. Roberts", Category: "ACCT UK Employee", IsVacant: false},
{PostID: "IS-307", Col: 3, Row: 6, Title: "Digital services officer", Occupant: "D. Robertson", Category: "ACCT UK Employee", IsVacant: false}
);
// 1 head + 8 + 8 + 7 = 24 posts, 1 vacancy - matches the mockup panel exactly.
- Render formula — same renderer shape as Phase 1, but
X/Yare computed viaAddColumns, not stored per row:
Set(varInfoSysSvg,
With(
{
_panelW: 371, _gutter: 10, _headH: 34, _headGap: 10, _cardH: 27, _rowGap: 3,
_colW: (371 - 2*10) / 3
},
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 371 260' font-family='Arial'>" &
"<rect x='0' y='0' width='371' height='260' fill='#FFFFFF'/>" &
Concat(
AddColumns(
colInfoSysPosts,
"_x", If(Col = 0, (_panelW - 140) / 2, (Col - 1) * (_colW + _gutter)),
"_y", If(Col = 0, 0, _headH + _headGap + Row * (_cardH + _rowGap)),
"_w", If(Col = 0, 140, _colW),
"_h", If(Col = 0, _headH, _cardH)
),
With(
{
_cat: LookUp(colCategoryColours, Category = ThisRecord.Category),
_safeTitle: Substitute(Substitute(Substitute(ThisRecord.Title, "&", "&"), "<", "<"), ">", ">"),
_safeOcc: Substitute(Substitute(Substitute(ThisRecord.Occupant, "&", "&"), "<", "<"), ">", ">")
},
"<rect x='" & _x & "' y='" & _y & "' width='" & _w & "' height='" & _h & "' rx='3' " &
"fill='" & If(IsVacant, "#F0F1F0", If(varChartMode = "Quiet", "#FFFFFF", Coalesce(_cat.Hex, "#FFFFFF"))) & "' " &
"stroke='" & If(IsVacant, "#6B7570", "#CBD5D1") & "' stroke-width='0.6'" &
If(IsVacant, " stroke-dasharray='2,2'", "") & "/>" &
If(varChartMode = "Quiet" && !IsVacant,
"<rect x='" & _x & "' y='" & _y & "' width='4' height='" & _h & "' fill='" & Coalesce(_cat.Hex, "#FFFFFF") & "'/>",
""
) &
"<text x='" & (_x + 6) & "' y='" & (_y + 12) & "' font-weight='bold' font-size='7' fill='#172A27'>" &
If(IsVacant, "GAPPED", _safeOcc) & "</text>" &
"<text x='" & (_x + 6) & "' y='" & (_y + 22) & "' font-size='7' fill='#172A27'>" & _safeTitle & "</text>"
),
""
) &
"</svg>"
)
);
Set(varInfoSysDataUri, "data:image/svg+xml;base64," & Base64(varInfoSysSvg))
Point a second Image control at =varInfoSysDataUri.
<text> does NOT auto-wrap; if real post titles are longer than this mockup's shortened labels, the renderer needs an explicit wrap step (split into <tspan> lines by measured character width) before this is production-safe. Record actual behaviour, don't assume it fits.
Next Steps (Not Started)
From the original brief, once Phases 0-2 above are confirmed:
- Layout and print with the actual structure — real 108 posts (not mockup labels), full page-grid layout across all 10 departments (only Information Systems' internal column packing is proven so far), a real
Print()A3 test, a defined legibility/overflow threshold. - Data and editing — SharePoint schema (PostID, ReportsToPostID, TeamID, OccupantName/person reference, PersonnelCategory, OccupancyStatus, display order), validation (cycles, missing parents, duplicate IDs), permissions at the data layer, not just hidden controls.
- Delivery and evidence — acceptance tests (fill vacancy, vacate-with-child, change category, move reporting post, long labels, invalid/cyclic relationship, full record count, both styles, real A3 export) become real once the data layer exists.
Known Limitations / Open Questions
- Only Command (hand-placed) and Information Systems (one department, computed grid) are drafted. The other 8 departments and the full-page composition (all panels together, per the measured page grid) are not yet built.
- Corner radius on cards is visually estimated (~3pt), not numerically confirmed from the PDF's path data.
Print()'s documented restrictions (no mobile, no multi-page, browser print-to-PDF only) haven't been tested against a real full-page render yet.- If any item above turns out to work (or not) when tested, add the finding to
C:\Users\Phaderon\Projects\PowerApps\reference\real-build-quirks.mdper the Bible's own "newly verified facts get added back" rule — not just left on this page.