-
-
Notifications
You must be signed in to change notification settings - Fork 570
fix(print-layout): improve atlas feature framing #1783
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
71aa3d9
dea711d
1a7b448
60cdc8a
91e72bd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,6 +42,7 @@ import { | |
| import { | ||
| computeScaleRatio, | ||
| drawLayout, | ||
| mapBodyAspectRatio, | ||
| PAPER_SIZES, | ||
| resolvePageSize, | ||
| type BodyCorner, | ||
|
|
@@ -58,6 +59,7 @@ import { | |
| DEFAULT_TABLE_ROWS, | ||
| layerRows, | ||
| MAX_TABLE_ROWS, | ||
| rowForAtlasFeature, | ||
| rowsWithinBounds, | ||
| type ChartBlockType, | ||
| } from "../../lib/print-data-blocks"; | ||
|
|
@@ -91,9 +93,11 @@ import { | |
| } from "../../lib/print-layout-export"; | ||
| import { | ||
| atlasEntryName, | ||
| atlasViewportFrame, | ||
| buildAtlasPages, | ||
| buildLineAtlasPages, | ||
| collectAtlasFeatures, | ||
| geometryBounds, | ||
| hasLineGeometry, | ||
| MAX_LINE_ATLAS_PAGES, | ||
| expandBounds, | ||
|
|
@@ -106,6 +110,7 @@ import { | |
| type AtlasPage, | ||
| type AtlasTokenContext, | ||
| } from "../../lib/print-atlas"; | ||
| import { clearAtlasFeatureMask, showAtlasFeatureMask } from "../../lib/print-atlas-mask"; | ||
|
|
||
| interface PrintLayoutDialogProps { | ||
| open: boolean; | ||
|
|
@@ -287,6 +292,7 @@ export function PrintLayoutDialog({ | |
| const [tableMaxRows, setTableMaxRows] = useState(DEFAULT_TABLE_ROWS); | ||
| const [tablePosition, setTablePosition] = useState<BodyCorner>("bottom-left"); | ||
| const [tableFilterToPage, setTableFilterToPage] = useState(true); | ||
| const [tableFilterToAtlasFeature, setTableFilterToAtlasFeature] = useState(false); | ||
| const [showDataChart, setShowDataChart] = useState(false); | ||
| const [chartLayerId, setChartLayerId] = useState(""); | ||
| const [chartTitle, setChartTitle] = useState(""); | ||
|
|
@@ -318,6 +324,7 @@ export function PrintLayoutDialog({ | |
| const [atlasNameField, setAtlasNameField] = useState(""); | ||
| const [atlasExtentMode, setAtlasExtentMode] = useState<"margin" | "scale">("margin"); | ||
| const [atlasMarginPct, setAtlasMarginPct] = useState(10); | ||
| const [atlasMaskEnabled, setAtlasMaskEnabled] = useState(false); | ||
| const [atlasScale, setAtlasScale] = useState("50000"); | ||
| const [atlasSortField, setAtlasSortField] = useState(""); | ||
| const [atlasSortDescending, setAtlasSortDescending] = useState(false); | ||
|
|
@@ -629,7 +636,10 @@ export function PrintLayoutDialog({ | |
| if (!atlasActiveRef.current) recapture(); | ||
| } else if (!open && wasOpenRef.current && !drawingRef.current) { | ||
| // Closing for good (not to draw): take the extent box off the map. | ||
| if (map) clearPrintExtent(map); | ||
| if (map) { | ||
| clearPrintExtent(map); | ||
| clearAtlasFeatureMask(map); | ||
| } | ||
| } | ||
| wasOpenRef.current = open; | ||
| }, [open, projectName, recapture, mapControllerRef, extentBbox]); | ||
|
|
@@ -657,6 +667,7 @@ export function PrintLayoutDialog({ | |
| idleRecaptureRef.current = null; | ||
| } | ||
| clearPrintExtent(map); | ||
| clearAtlasFeatureMask(map); | ||
| } | ||
| }, | ||
| [mapControllerRef], | ||
|
|
@@ -812,6 +823,17 @@ export function PrintLayoutDialog({ | |
| () => atlasLayers.find((l) => l.id === atlasLayerId) ?? null, | ||
| [atlasLayers, atlasLayerId], | ||
| ); | ||
| const atlasMaskAvailable = useMemo( | ||
| () => | ||
| atlasCoverage === "features" && | ||
| Boolean( | ||
| atlasLayer?.geojson?.features.some( | ||
| (feature) => | ||
| feature.geometry?.type === "Polygon" || feature.geometry?.type === "MultiPolygon", | ||
| ), | ||
| ), | ||
| [atlasCoverage, atlasLayer], | ||
| ); | ||
| // The per-vertex geometry walk runs once per coverage layer; sort/filter | ||
| // edits below only re-iterate these lightweight per-feature records. | ||
| const atlasFeatureInfos = useMemo( | ||
|
|
@@ -881,6 +903,14 @@ export function PrintLayoutDialog({ | |
| const currentAtlasPage = atlasEnabled ? (atlasPages[clampedAtlasIndex] ?? null) : null; | ||
| const atlasActive = atlasEnabled && atlasPageCount > 0; | ||
| atlasActiveRef.current = atlasActive; | ||
| // The mask is a temporary live-map layer. Remove it immediately when the | ||
| // option, atlas, or dialog is turned off instead of waiting for another | ||
| // camera drive that may never happen. | ||
| useEffect(() => { | ||
| if (open && atlasActive && atlasMaskEnabled && atlasMaskAvailable) return; | ||
| const map = mapControllerRef.current?.getMap(); | ||
| if (map) clearAtlasFeatureMask(map); | ||
| }, [open, atlasActive, atlasMaskEnabled, atlasMaskAvailable, mapControllerRef]); | ||
| const atlasFilterValid = atlasFilterPredicate !== null; | ||
| const atlasScaleValid = atlasExtentMode !== "scale" || Number(atlasScale) > 0; | ||
| // A floor (not just > 0) keeps a mistyped tiny length from cutting a long | ||
|
|
@@ -921,6 +951,9 @@ export function PrintLayoutDialog({ | |
| () => atlasLayers.find((l) => l.id === chartLayerId) ?? null, | ||
| [atlasLayers, chartLayerId], | ||
| ); | ||
| const tableUsesAtlasLayer = Boolean( | ||
| atlasEnabled && atlasLayer && tableLayer?.id === atlasLayer.id, | ||
| ); | ||
| const tableFields = useMemo( | ||
| () => (tableLayer?.geojson ? listAtlasFields(tableLayer.geojson.features) : []), | ||
| [tableLayer], | ||
|
|
@@ -1093,14 +1126,19 @@ export function PrintLayoutDialog({ | |
| const displayTableRows = useMemo( | ||
| () => | ||
| showDataTable | ||
| ? rowsForBlock(tableFeatureInfos, tableAllRows, tableFilterToPage, displayFilterBounds) | ||
| ? tableFilterToAtlasFeature && tableUsesAtlasLayer && currentAtlasPage | ||
| ? rowForAtlasFeature(tableAllRows, currentAtlasPage.sourceIndex) | ||
| : rowsForBlock(tableFeatureInfos, tableAllRows, tableFilterToPage, displayFilterBounds) | ||
| : [], | ||
| [ | ||
| showDataTable, | ||
| rowsForBlock, | ||
| tableFeatureInfos, | ||
| tableAllRows, | ||
| tableFilterToPage, | ||
| tableFilterToAtlasFeature, | ||
| tableUsesAtlasLayer, | ||
| currentAtlasPage, | ||
| displayFilterBounds, | ||
| ], | ||
| ); | ||
|
|
@@ -1163,25 +1201,49 @@ export function PrintLayoutDialog({ | |
| // Drive the live map to one atlas page's extent and capture it. Margin mode | ||
| // grows the feature's box before fitting; fixed-scale mode fits first, then | ||
| // corrects the zoom by the log2 ratio difference (like applyScale) and | ||
| // recaptures. Returns the capture plus the map's final visible bounds, so | ||
| // the data blocks can filter to what the page actually shows. | ||
| // recaptures. Returns the capture plus the print frame's final visible | ||
| // bounds, so data blocks exclude the part of the live map that cover-crop | ||
| // removes from the page. | ||
| const captureAtlasPage = useCallback( | ||
| async (page: AtlasPage): Promise<{ cap: CapturedMap; viewBounds: AtlasBounds }> => { | ||
| const map = mapControllerRef.current?.getMap(); | ||
| if (!map) throw new Error("Map is not ready"); | ||
| const ctx: AtlasTokenContext = { | ||
| name: page.name, | ||
| pageNumber: page.index + 1, | ||
| total: atlasPageCount, | ||
| properties: page.properties, | ||
| }; | ||
| const pageOptions: LayoutOptions = { | ||
| ...options, | ||
| title: substituteAtlasTokens(options.title, ctx), | ||
| subtitle: substituteAtlasTokens(options.subtitle, ctx), | ||
| footerText: substituteAtlasTokens(options.footerText, ctx), | ||
| }; | ||
| const containMap = Boolean(map.getLayer(GRATICULE_LABEL_LAYER_ID)); | ||
| const canvas = map.getCanvas(); | ||
| const viewportWidth = canvas.clientWidth || canvas.width; | ||
| const viewportHeight = canvas.clientHeight || canvas.height; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Confidence: low-to-medium — this only triggers when |
||
| const targetAspect = containMap | ||
| ? viewportWidth / Math.max(1, viewportHeight) | ||
| : mapBodyAspectRatio(pageOptions); | ||
| const viewportFrame = atlasViewportFrame(viewportWidth, viewportHeight, targetAspect); | ||
| const coverageFeature = atlasLayer?.geojson?.features[page.sourceIndex]; | ||
| if (atlasMaskEnabled) showAtlasFeatureMask(map, coverageFeature); | ||
| else clearAtlasFeatureMask(map); | ||
| const [w, s, e, n] = expandBounds(page.bounds, atlasFitMarginPct); | ||
| map.fitBounds( | ||
| [ | ||
| [w, s], | ||
| [e, n], | ||
| ], | ||
| { animate: false, padding: 0 }, | ||
| { animate: false, padding: viewportFrame.padding }, | ||
| ); | ||
| await waitForAtlasSettle(map); | ||
| // Mirror recapture: an active graticule draws coordinate labels at the | ||
| // map edges, so fit with "contain" to keep them un-cropped on every | ||
| // atlas page (mapFit is persistent state, so it must be set here too). | ||
| setMapFit(map.getLayer(GRATICULE_LABEL_LAYER_ID) ? "contain" : "cover"); | ||
| setMapFit(containMap ? "contain" : "cover"); | ||
| // Hide the drawn print-extent box while reading the buffer, as recapture | ||
| // does, so its outline is never baked into a page. | ||
| const capture = () => { | ||
|
|
@@ -1199,17 +1261,8 @@ export function PrintLayoutDialog({ | |
| // a title/footer made purely of tokens can resolve to empty for a | ||
| // given feature, which collapses that row and changes the body height | ||
| // the scale is computed from. | ||
| const ctx: AtlasTokenContext = { | ||
| name: page.name, | ||
| pageNumber: page.index + 1, | ||
| total: atlasPageCount, | ||
| properties: page.properties, | ||
| }; | ||
| const ratio = computeScaleRatio({ | ||
| ...options, | ||
| title: substituteAtlasTokens(options.title, ctx), | ||
| subtitle: substituteAtlasTokens(options.subtitle, ctx), | ||
| footerText: substituteAtlasTokens(options.footerText, ctx), | ||
| ...pageOptions, | ||
| metersPerPixel: cap.metersPerPixel, | ||
| mapPixelRatio: cap.pixelRatio, | ||
| bearingDeg: cap.bearingDeg, | ||
|
|
@@ -1235,10 +1288,24 @@ export function PrintLayoutDialog({ | |
| } else { | ||
| setAtlasScaleNotice(null); | ||
| } | ||
| const frameBounds = containMap | ||
| ? null | ||
| : geometryBounds({ | ||
| type: "MultiPoint", | ||
| coordinates: [ | ||
| [viewportFrame.crop.left, viewportFrame.crop.top], | ||
| [viewportFrame.crop.right, viewportFrame.crop.top], | ||
| [viewportFrame.crop.right, viewportFrame.crop.bottom], | ||
| [viewportFrame.crop.left, viewportFrame.crop.bottom], | ||
| ].map(([x, y]) => { | ||
| const point = map.unproject([x, y]); | ||
| return [point.lng, point.lat]; | ||
| }), | ||
| }); | ||
| const b = map.getBounds(); | ||
| return { | ||
| cap, | ||
| viewBounds: [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()], | ||
| viewBounds: frameBounds ?? [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()], | ||
| }; | ||
| }, | ||
| [ | ||
|
|
@@ -1247,6 +1314,8 @@ export function PrintLayoutDialog({ | |
| atlasFitMarginPct, | ||
| atlasScale, | ||
| atlasPageCount, | ||
| atlasLayer, | ||
| atlasMaskEnabled, | ||
| waitForAtlasSettle, | ||
| options, | ||
| t, | ||
|
|
@@ -1340,6 +1409,7 @@ export function PrintLayoutDialog({ | |
| atlasExtentMode, | ||
| atlasMarginPct, | ||
| atlasScale, | ||
| atlasMaskEnabled, | ||
| // Along-a-line coverage: a new segment length can keep the same page | ||
| // count (sourceIndex signature unchanged) while every extent moved. | ||
| atlasCoverage, | ||
|
|
@@ -1635,7 +1705,9 @@ export function PrintLayoutDialog({ | |
| // Each page's table/chart re-filters to the extent the page's | ||
| // capture actually shows (not just the nominal feature bounds). | ||
| ...buildBlocksFromRows( | ||
| rowsForBlock(tableFeatureInfos, tableAllRows, tableFilterToPage, viewBounds), | ||
| tableFilterToAtlasFeature && tableUsesAtlasLayer | ||
| ? rowForAtlasFeature(tableAllRows, pages[i].sourceIndex) | ||
| : rowsForBlock(tableFeatureInfos, tableAllRows, tableFilterToPage, viewBounds), | ||
| rowsForBlock(chartFeatureInfos, chartAllRows, chartFilterToPage, viewBounds), | ||
| ), | ||
| title: substituteAtlasTokens(options.title, ctx), | ||
|
|
@@ -2191,6 +2263,20 @@ export function PrintLayoutDialog({ | |
| )} | ||
| </div> | ||
| )} | ||
| {atlasMaskAvailable && ( | ||
| <div className="space-y-1.5"> | ||
| <ToggleField | ||
| id="atlas-mask-outside" | ||
| label={t("printLayout.atlas.maskOutside")} | ||
| checked={atlasMaskEnabled} | ||
| disabled={atlasBusy} | ||
| onChange={setAtlasMaskEnabled} | ||
| /> | ||
| <p className="text-xs text-muted-foreground"> | ||
| {t("printLayout.atlas.maskOutsideHint")} | ||
| </p> | ||
| </div> | ||
| )} | ||
| {/* Along-a-line pages follow the line's own chainage, | ||
| so ordering controls only apply per-feature mode. */} | ||
| {atlasCoverage === "features" && ( | ||
|
|
@@ -2705,11 +2791,26 @@ export function PrintLayoutDialog({ | |
| id="dt-filter-page" | ||
| label={t("printLayout.dataBlocks.filterToPage")} | ||
| checked={tableFilterToPage} | ||
| disabled={tableFilterToAtlasFeature && tableUsesAtlasLayer} | ||
| onChange={setTableFilterToPage} | ||
| /> | ||
| <p className="text-xs text-muted-foreground"> | ||
| {t("printLayout.dataBlocks.filterToPageHint")} | ||
| </p> | ||
| {atlasEnabled && ( | ||
| <> | ||
| <ToggleField | ||
| id="dt-filter-atlas-feature" | ||
| label={t("printLayout.dataBlocks.filterToAtlasFeature")} | ||
| checked={tableFilterToAtlasFeature} | ||
| disabled={!tableUsesAtlasLayer} | ||
| onChange={setTableFilterToAtlasFeature} | ||
| /> | ||
| <p className="text-xs text-muted-foreground"> | ||
| {t("printLayout.dataBlocks.filterToAtlasFeatureHint")} | ||
| </p> | ||
| </> | ||
| )} | ||
| {!displayDataBlocks.dataTable && ( | ||
| <p className="text-xs text-muted-foreground"> | ||
| {t("printLayout.dataTable.noRows")} | ||
|
|
@@ -3028,7 +3129,9 @@ export function PrintLayoutDialog({ | |
| // encodeURIComponent, which leaves ( ) unescaped, and an | ||
| // unquoted ) (common in SVG: translate(), rgba(), url(#id)) | ||
| // would prematurely close the CSS url() token. | ||
| style={{ backgroundImage: `url("${svgSrc}")` }} | ||
| style={{ | ||
| backgroundImage: `url("${svgSrc}")`, | ||
| }} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1315,7 +1315,9 @@ | |
| "titleLabel": "En-tête", | ||
| "position": "Position", | ||
| "filterToPage": "Seulement les entités dans l'emprise de la page", | ||
| "filterToPageHint": "S'applique aux pages d'atlas et à une emprise d'impression dessinée ; sinon, la couche entière est utilisée." | ||
| "filterToPageHint": "S'applique aux pages d'atlas et à une emprise d'impression dessinée ; sinon, la couche entière est utilisée.", | ||
| "filterToAtlasFeature": "Seulement l’entité courante de l’atlas", | ||
| "filterToAtlasFeatureHint": "Disponible lorsque la table utilise la couche de couverture de l’atlas. Ce filtre est prioritaire sur l’emprise de la page." | ||
|
Comment on lines
+1319
to
+1320
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor style nit: these two new strings use a curly apostrophe ( Confidence: low — purely cosmetic, doesn't affect functionality. |
||
| }, | ||
| "dataTable": { | ||
| "columns": "Colonnes", | ||
|
|
@@ -1445,6 +1447,8 @@ | |
| "extentMargin": "Marge autour de l'entité", | ||
| "extentScale": "Échelle fixe", | ||
| "marginLabel": "Marge (%)", | ||
| "maskOutside": "Masquer la zone hors de l’entité courante", | ||
| "maskOutsideHint": "Applique un remplissage inversé translucide pour mettre en évidence l’entité courante de l’atlas.", | ||
| "scaleLabel": "Échelle", | ||
| "scaleRequired": "Entrez une échelle supérieure à zéro.", | ||
| "sortField": "Trier par", | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Edge case: when
tableFilterToAtlasFeatureis checked butcurrentAtlasPageis momentarilynull(e.g. the atlas filter currently yields 0 pages, or a newly selected page hasn't finished driving), this silently falls back torowsForBlock(...)(page-extent/full-layer rows) instead of showing an empty table. That contradicts the hint text ("This takes priority over the page extent filter") and could surprise a user who expects "only the current feature" to mean "none" rather than "whatever the page-extent filter would show."Same pattern applies to the export path at line ~1709 (
tableFilterToAtlasFeature && tableUsesAtlasLayer ? rowForAtlasFeature(...) : rowsForBlock(...)), though therepages[i]is always defined so it's less reachable in practice.Confidence: medium — this is a real behavioral gap, though it only surfaces in the narrow window where the atlas is enabled with a matching table layer but has no current page.