Skip to content

Commit acbb426

Browse files
ChristianHuehnchristian-huehn-mwclaude
authored
feat(visualization): add per-map option for top labels (#4487)
When more than one map is selected in standard mode, a new 'All maps | Per map' toggle in the label settings splits the node-selecting label computations per map: top-N labels are picked separately for each map (Height and Color mode), and overlapping labels are only grouped within the same map. Hidden for a single map and in delta mode; defaults to the previous global behavior. Co-authored-by: Christian Hühn <christian.huehn@maibornwolff.de> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 2ee1594 commit acbb426

30 files changed

Lines changed: 596 additions & 20 deletions
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
---
2+
name: per-map-top-labels
3+
issue: -
4+
state: complete
5+
version: 1
6+
---
7+
8+
## Goal
9+
10+
When more than one map is selected (standard mode), a new "per map" radio option in the label
11+
settings splits all node-selecting label computations per map: Top-N labels are picked per map
12+
(N labels each), color-mode label selection runs per map, and overlapping-label grouping only
13+
groups labels belonging to the same map. Default stays "across all maps" (current behavior).
14+
15+
## Tasks
16+
17+
### 1. New appSettings slice `labelsPerMap` (boolean, default false)
18+
- actions/reducer/selector in `state/store/appSettings/labelsPerMap/`, mirroring `amountOfTopLabels`
19+
- register in appSettings reducer, `AppSettings` model type, default state
20+
21+
### 2. Feature wiring in `features/labelSettings`
22+
- `labelsPerMap.store.ts` + `labelsPerMap.service.ts` following existing store/service pattern
23+
- export selector from `labelSettings.selectors.ts`
24+
- add `appSettings.labelsPerMap` to `resetSettingsKeys` in the panel
25+
26+
### 3. UI radio in labelSettingsPanel
27+
- radio group under the Top Labels slider: `(•) across all maps ( ) per map`, styled like the
28+
existing Height/Color join radio
29+
- only visible when 2+ maps are visible in standard mode (hidden for single map and delta mode):
30+
expose a `isMultipleMapsView$` via StateAccessStore based on visibleFileStates count + isDeltaState
31+
32+
### 4. Per-map top-N selection in `codeMap.render.service.ts` `setLabels`
33+
- helper to derive a map key from `node.path` (top-level segment under root; AggregationGenerator
34+
prefixes paths with `/root/<fileName>/` when maps are merged)
35+
- height mode: partition leaf nodes by map key, `selectTopNByValue` per partition (N each)
36+
- color mode: same partitioning applied to the selected color nodes
37+
- ignore the setting (keep global behavior) when only one map is visible or in delta mode
38+
39+
### 5. Per-map collision grouping in `labelCollision.service.ts`
40+
- when per-map mode is active, `buildCollisionGroups` only unions overlapping labels that share
41+
the same map key
42+
43+
### 6. Tests
44+
- reducer default/toggle
45+
- render service: per-map vs global selection (height + color mode), single-map/delta fallback
46+
- panel: radio visibility and dispatch
47+
- collision service: overlapping labels from different maps are not grouped in per-map mode
48+
49+
### 7. Housekeeping
50+
- CHANGELOG.md entry
51+
52+
## Steps
53+
54+
- [x] Complete Task 1: state slice `labelsPerMap`
55+
- [x] Complete Task 2: feature store/service/selector wiring
56+
- [x] Complete Task 3: UI radio with multi-map visibility
57+
- [x] Complete Task 4: per-map top-N selection in render service
58+
- [x] Complete Task 5: per-map collision grouping
59+
- [x] Complete Task 6: tests
60+
- [x] Complete Task 7: changelog
61+
62+
## Notes
63+
64+
- Clarified with user: one shared set of setting values; only node-selecting computations split
65+
per map ("N per map", so top 10 with 3 maps = up to 30 labels). No independent per-map settings.
66+
- Option hidden (not just disabled) for single map and delta mode.
67+
- Branch from `main`: `feature/per-map-top-labels`.
68+
- New `labelsPerMapActiveSelector` (state/selectors) gates the behavior; the labelSettings feature
69+
reaches it via `StateAccessStore.isLabelsPerMapActive()` because `ui/` may not import feature
70+
internals (dependency-cruiser rules).
71+
- `loadInitialFile.service.ts` needed the new key in `optionalAppSettingsKeys` plus a
72+
`mapAppSettingToAction` case — its switch throws on unknown saved-config keys.
73+
- Scenarios intentionally left untouched: persisting the flag would break previously saved
74+
scenarios (undefined patch values) and was not requested.
75+
- Verification: `tsc --noEmit` clean, full unit suite green (381 suites / 2222 tests),
76+
`npm run lint:architecture` 0 errors, Biome clean.

visualization/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/)
99

1010
### Added 🚀
1111

12+
- **Per-map top labels**: When more than one map is selected, the Label Settings panel offers a new `All maps | Per map` toggle below the Top Labels slider. In `Per map` mode the top-N labels are picked separately for each map (Height and Color label mode alike), and overlapping labels are only grouped within the same map. The toggle is hidden for a single map and in delta mode.
1213
- **Label Size slider**: New slider in the Label Settings panel scales floating label text (name, metric value, and "+N more" badge) between 0.75× and 2.5×. The setting is preserved in scenarios.
1314
- **File Explorer sidebar**: The file tree was redesigned as a dedicated left-side sidebar drawer that overlays the codemap. The drawer adds a Shown/Flattened/Hidden chip row at the top, a sort control, and click-to-edit popovers for the active flatten and exclude rules.
1415
- **Collapsible File Explorer**: The sidebar can now be collapsed via the `«` button in the header. Collapsed mode shows a small floating search box (with the kebab Flatten/Exclude menu) pinned at the top-left, freeing the full viewport for the codemap. Re-expand with the folder-tree button.

visualization/app/codeCharta/codeCharta.model.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ export interface AppSettings {
171171
colorLabels: ColorLabelOptions
172172
labelMode: LabelMode
173173
groupLabelCollisions: boolean
174+
labelsPerMap: boolean
174175
isColorMetricLinkedToHeightMetric: boolean
175176
enableFloorLabels: boolean
176177
}

visualization/app/codeCharta/features/labelSettings/components/labelSettingsPanel/labelSettingsPanel.component.html

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<div [title]="'Display the labels of the ' + amountOfTopLabels() + ' highest buildings'">
1+
<div [title]="topLabelsTitle()">
22
<span class="text-sm">Top Labels</span>
33
<div class="flex items-center gap-2">
44
<input type="range" class="range range-primary range-sm flex-1"
@@ -10,6 +10,22 @@
1010
</div>
1111
</div>
1212

13+
@if (areMultipleMapsVisible()) {
14+
<div class="join w-full" aria-label="Top labels scope"
15+
title="Pick the top labels across all maps combined or separately for each map">
16+
<input type="radio" name="labelsPerMap"
17+
class="join-item btn btn-sm flex-1"
18+
aria-label="All maps"
19+
[checked]="!labelsPerMap()"
20+
(change)="setLabelsPerMap(false)" />
21+
<input type="radio" name="labelsPerMap"
22+
class="join-item btn btn-sm flex-1"
23+
aria-label="Per map"
24+
[checked]="labelsPerMap()"
25+
(change)="setLabelsPerMap(true)" />
26+
</div>
27+
}
28+
1329
<div title="Scale floating label font size">
1430
<span class="text-sm">Label Size</span>
1531
<div class="flex items-center gap-2">

visualization/app/codeCharta/features/labelSettings/components/labelSettingsPanel/labelSettingsPanel.component.spec.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import userEvent from "@testing-library/user-event"
44
import { setColorLabels } from "../../../../state/store/appSettings/colorLabels/colorLabels.actions"
55
import { setLabelMode } from "../../../../state/store/appSettings/labelMode/labelMode.actions"
66
import { setLabelSize } from "../../../../state/store/appSettings/labelSize/labelSize.actions"
7+
import { setLabelsPerMap } from "../../../../state/store/appSettings/labelsPerMap/labelsPerMap.actions"
8+
import { setDelta, setFiles } from "../../../../state/store/files/files.actions"
9+
import { FILE_STATES_TWO_FILES, TEST_FILE_DATA, TEST_FILE_DATA_JAVA } from "../../../../util/dataMocks"
710
import { fireEvent } from "@testing-library/angular"
811
import { LabelSettingsPanelComponent } from "./labelSettingsPanel.component"
912
import { Store, StoreModule } from "@ngrx/store"
@@ -154,6 +157,58 @@ describe("LabelSettingsPanelComponent", () => {
154157
expect(positiveCheckbox.disabled).toBe(false)
155158
})
156159

160+
describe("per-map top labels toggle", () => {
161+
it("should hide the toggle when only one map is visible", async () => {
162+
// Arrange & Act
163+
await render(LabelSettingsPanelComponent)
164+
165+
// Assert
166+
expect(screen.queryByRole("radio", { name: "Per map" })).toBe(null)
167+
})
168+
169+
it("should display the toggle when multiple maps are visible in standard mode", async () => {
170+
// Arrange
171+
const { detectChanges } = await render(LabelSettingsPanelComponent)
172+
const store = TestBed.inject(Store)
173+
174+
// Act
175+
store.dispatch(setFiles({ value: FILE_STATES_TWO_FILES }))
176+
detectChanges()
177+
178+
// Assert
179+
expect(screen.getByRole("radio", { name: "All maps" })).not.toBe(null)
180+
expect(screen.getByRole("radio", { name: "Per map" })).not.toBe(null)
181+
})
182+
183+
it("should hide the toggle in delta mode", async () => {
184+
// Arrange
185+
const { detectChanges } = await render(LabelSettingsPanelComponent)
186+
const store = TestBed.inject(Store)
187+
188+
// Act
189+
store.dispatch(setDelta({ referenceFile: TEST_FILE_DATA, comparisonFile: TEST_FILE_DATA_JAVA }))
190+
detectChanges()
191+
192+
// Assert
193+
expect(screen.queryByRole("radio", { name: "Per map" })).toBe(null)
194+
})
195+
196+
it("should dispatch setLabelsPerMap when clicking Per map", async () => {
197+
// Arrange
198+
const { detectChanges } = await render(LabelSettingsPanelComponent)
199+
const store = TestBed.inject(Store)
200+
store.dispatch(setFiles({ value: FILE_STATES_TWO_FILES }))
201+
detectChanges()
202+
const dispatchSpy = jest.spyOn(store, "dispatch")
203+
204+
// Act
205+
await userEvent.click(screen.getByRole("radio", { name: "Per map" }))
206+
207+
// Assert
208+
expect(dispatchSpy).toHaveBeenCalledWith(setLabelsPerMap({ value: true }))
209+
})
210+
})
211+
157212
describe("Label Size slider", () => {
158213
const flushDebounce = () => new Promise(resolve => setTimeout(resolve, 500))
159214

visualization/app/codeCharta/features/labelSettings/components/labelSettingsPanel/labelSettingsPanel.component.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ShowMetricLabelNodeNameService } from "../../services/showMetricLabelNo
88
import { ShowMetricLabelNameValueService } from "../../services/showMetricLabelNameValue.service"
99
import { ColorLabelsService } from "../../services/colorLabels.service"
1010
import { GroupLabelCollisionsService } from "../../services/groupLabelCollisions.service"
11+
import { LabelsPerMapService } from "../../services/labelsPerMap.service"
1112
import { StateAccessStore } from "../../stores/stateAccess.store"
1213
import { CodeMapRenderService } from "../../../../ui/codeMap/codeMap.render.service"
1314
import { debounce } from "../../../../util/debounce"
@@ -31,6 +32,7 @@ export class LabelSettingsPanelComponent {
3132
private readonly showMetricLabelNameValueService = inject(ShowMetricLabelNameValueService)
3233
private readonly colorLabelsService = inject(ColorLabelsService)
3334
private readonly groupLabelCollisionsService = inject(GroupLabelCollisionsService)
35+
private readonly labelsPerMapService = inject(LabelsPerMapService)
3436
private readonly stateAccessStore = inject(StateAccessStore)
3537
private readonly codeMapRenderService = inject(CodeMapRenderService)
3638

@@ -46,7 +48,8 @@ export class LabelSettingsPanelComponent {
4648
"appSettings.showMetricLabelNameValue",
4749
"appSettings.colorLabels",
4850
"appSettings.labelMode",
49-
"appSettings.groupLabelCollisions"
51+
"appSettings.groupLabelCollisions",
52+
"appSettings.labelsPerMap"
5053
]
5154

5255
readonly amountOfTopLabels = toSignal(this.amountOfTopLabelsService.amountOfTopLabels$(), { requireSync: true })
@@ -59,9 +62,16 @@ export class LabelSettingsPanelComponent {
5962
readonly labelMode = toSignal(this.labelModeService.labelMode$(), { requireSync: true })
6063
readonly colorCategoryCounts = toSignal(this.codeMapRenderService.colorCategoryCounts$, { requireSync: true })
6164
readonly groupLabelCollisions = toSignal(this.groupLabelCollisionsService.groupLabelCollisions$(), { requireSync: true })
65+
readonly labelsPerMap = toSignal(this.labelsPerMapService.labelsPerMap$(), { requireSync: true })
66+
readonly areMultipleMapsVisible = toSignal(this.stateAccessStore.areMultipleMapsVisible$, { requireSync: true })
6267

6368
readonly showColorLabels = computed(() => this.labelMode() === LabelMode.Color && !this.isDeltaState())
6469

70+
readonly topLabelsTitle = computed(() => {
71+
const base = `Display the labels of the ${this.amountOfTopLabels()} highest buildings`
72+
return this.labelsPerMap() && this.areMultipleMapsVisible() ? `${base} per map` : base
73+
})
74+
6575
readonly applyDebouncedTopLabels = debounce((amountOfTopLabels: number) => {
6676
this.amountOfTopLabelsService.setAmountOfTopLabels(amountOfTopLabels)
6777
}, LabelSettingsPanelComponent.DEBOUNCE_TIME)
@@ -118,6 +128,10 @@ export class LabelSettingsPanelComponent {
118128
this.groupLabelCollisionsService.setGroupLabelCollisions((event.target as HTMLInputElement).checked)
119129
}
120130

131+
setLabelsPerMap(value: boolean) {
132+
this.labelsPerMapService.setLabelsPerMap(value)
133+
}
134+
121135
resetSettings() {
122136
this.stateAccessStore.resetSettings(this.resetSettingsKeys)
123137
}

visualization/app/codeCharta/features/labelSettings/selectors/labelSettings.selectors.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,5 @@ export const showMetricLabelNameValueSelector = createSelector(appSettingsSelect
1414
export const colorLabelsSelector = createSelector(appSettingsSelector, appSettings => appSettings.colorLabels)
1515

1616
export const groupLabelCollisionsSelector = createSelector(appSettingsSelector, appSettings => appSettings.groupLabelCollisions)
17+
18+
export const labelsPerMapSelector = createSelector(appSettingsSelector, appSettings => appSettings.labelsPerMap)

visualization/app/codeCharta/features/labelSettings/services/labelCollision.service.spec.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import { Store, StoreModule } from "@ngrx/store"
1212
import { appReducers, setStateMiddleware } from "../../../state/store/state.manager"
1313
import { StateAccessStore } from "../stores/stateAccess.store"
1414
import { setHeightMetric } from "../../../state/store/dynamicSettings/heightMetric/heightMetric.actions"
15+
import { setLabelsPerMap } from "../../../state/store/appSettings/labelsPerMap/labelsPerMap.actions"
16+
import { setFiles } from "../../../state/store/files/files.actions"
17+
import { FILE_STATES_TWO_FILES } from "../../../util/dataMocks"
1518

1619
describe("LabelCollisionService", () => {
1720
let store: Store<CcState>
@@ -303,6 +306,70 @@ describe("LabelCollisionService", () => {
303306
})
304307
})
305308

309+
describe("per-map collision grouping", () => {
310+
beforeEach(() => {
311+
store.dispatch(setShowMetricLabelNodeName({ value: true }))
312+
store.dispatch(setHeightMetric({ value: "mcc" }))
313+
})
314+
315+
function addOverlappingLabelsForMaps(pathOfFirst: string, pathOfSecond: string) {
316+
const firstLeaf = { ...sampleLeaf, name: "first", path: pathOfFirst, attributes: { mcc: 100 } } as undefined as Node
317+
const secondLeaf = { ...otherSampleLeaf, name: "second", path: pathOfSecond, attributes: { mcc: 10 } } as undefined as Node
318+
labelCreationService.addLeafLabel(firstLeaf, 0)
319+
labelCreationService.addLeafLabel(secondLeaf, 0)
320+
const sharedRect = makeRect(100, 120, 50, 150)
321+
stubRectsForLabels([sharedRect, sharedRect])
322+
}
323+
324+
it("should not group overlapping labels from different maps when labelsPerMap is active", () => {
325+
// Arrange
326+
store.dispatch(setFiles({ value: FILE_STATES_TWO_FILES }))
327+
store.dispatch(setLabelsPerMap({ value: true }))
328+
addOverlappingLabelsForMaps("/root/mapA/first", "/root/mapB/second")
329+
330+
// Act
331+
labelCollisionService.updateLabelLayout()
332+
333+
// Assert — labels of different maps stay visible side by side
334+
const contentFirst = labelCreationService.getLabels()[0].labelElement.getContentElement()
335+
const contentSecond = labelCreationService.getLabels()[1].labelElement.getContentElement()
336+
expect(contentFirst.style.opacity).toBe("1")
337+
expect(contentSecond.style.opacity).toBe("1")
338+
})
339+
340+
it("should still group overlapping labels of the same map when labelsPerMap is active", () => {
341+
// Arrange
342+
store.dispatch(setFiles({ value: FILE_STATES_TWO_FILES }))
343+
store.dispatch(setLabelsPerMap({ value: true }))
344+
addOverlappingLabelsForMaps("/root/mapA/first", "/root/mapA/second")
345+
346+
// Act
347+
labelCollisionService.updateLabelLayout()
348+
349+
// Assert
350+
const contentFirst = labelCreationService.getLabels()[0].labelElement.getContentElement()
351+
const contentSecond = labelCreationService.getLabels()[1].labelElement.getContentElement()
352+
expect(contentFirst.style.opacity).toBe("1")
353+
expect(contentSecond.style.opacity).toBe("0")
354+
})
355+
356+
it("should group overlapping labels across maps when labelsPerMap is off", () => {
357+
// Arrange
358+
store.dispatch(setFiles({ value: FILE_STATES_TWO_FILES }))
359+
store.dispatch(setLabelsPerMap({ value: false }))
360+
addOverlappingLabelsForMaps("/root/mapA/first", "/root/mapB/second")
361+
362+
// Act
363+
labelCollisionService.updateLabelLayout()
364+
365+
// Assert
366+
const contentFirst = labelCreationService.getLabels()[0].labelElement.getContentElement()
367+
const contentSecond = labelCreationService.getLabels()[1].labelElement.getContentElement()
368+
expect(contentFirst.style.opacity).toBe("1")
369+
expect(contentSecond.style.opacity).toBe("0")
370+
})
371+
})
372+
306373
describe("tooltip collision padding", () => {
307374
beforeEach(() => {
308375
store.dispatch(setShowMetricLabelNodeName({ value: true }))

visualization/app/codeCharta/features/labelSettings/services/labelCollision.service.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { LabelMode } from "../../../codeCharta.model"
66
import { StateAccessStore } from "../stores/stateAccess.store"
77
import { ConnectorDrawingService, LabelLayoutInfo } from "./connectorDrawing.service"
88
import { LABEL_GAP_PX, MAX_DISPLACEMENT_PX, TOOLTIP_COLLISION_PADDING_PX } from "./label.constants"
9+
import { getTopLevelMapName } from "../../../util/nodePathHelper"
910

1011
@Injectable({ providedIn: "root" })
1112
export class LabelCollisionService {
@@ -94,7 +95,7 @@ export class LabelCollisionService {
9495
return
9596
}
9697

97-
const groups = this.buildCollisionGroups(activeInfos)
98+
const groups = this.buildCollisionGroups(activeInfos, this.stateAccessStore.isLabelsPerMapActive())
9899
const { dynamicSettings } = this.stateAccessStore.getValue()
99100
const metric = appSettings.labelMode === LabelMode.Color ? dynamicSettings.colorMetric : dynamicSettings.heightMetric
100101

@@ -115,9 +116,10 @@ export class LabelCollisionService {
115116
}
116117
}
117118

118-
private buildCollisionGroups(infos: LabelLayoutInfo[]): LabelLayoutInfo[][] {
119+
private buildCollisionGroups(infos: LabelLayoutInfo[], groupPerMap: boolean): LabelLayoutInfo[][] {
119120
const n = infos.length
120121
const parent = Array.from({ length: n }, (_, i) => i)
122+
const mapNames = groupPerMap ? infos.map(info => getTopLevelMapName(info.label.node.path)) : undefined
121123

122124
const find = (x: number): number => {
123125
while (parent[x] !== x) {
@@ -137,6 +139,9 @@ export class LabelCollisionService {
137139

138140
for (let i = 0; i < n; i++) {
139141
for (let j = i + 1; j < n; j++) {
142+
if (mapNames !== undefined && mapNames[i] !== mapNames[j]) {
143+
continue
144+
}
140145
if (this.rectsOverlap(infos[i].rect, infos[j].rect)) {
141146
union(i, j)
142147
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Injectable } from "@angular/core"
2+
import { LabelsPerMapStore } from "../stores/labelsPerMap.store"
3+
4+
@Injectable({
5+
providedIn: "root"
6+
})
7+
export class LabelsPerMapService {
8+
constructor(private readonly labelsPerMapStore: LabelsPerMapStore) {}
9+
10+
labelsPerMap$() {
11+
return this.labelsPerMapStore.labelsPerMap$
12+
}
13+
14+
setLabelsPerMap(value: boolean) {
15+
this.labelsPerMapStore.setLabelsPerMap(value)
16+
}
17+
}

0 commit comments

Comments
 (0)