pass aspect ratio to fixedAspectRatio; remove printAreaDraw after printArea is defined

This commit is contained in:
2025-03-07 16:50:54 +09:00
parent 0ca6c136e3
commit ada3b63b8d
6 changed files with 485 additions and 201 deletions

View File

@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { jsPDF } from "jspdf"
import 'ol/ol.css' import 'ol/ol.css'
import { Modify } from 'ol/interaction' import { Modify } from 'ol/interaction'
import { ImageStatic, Vector as VectorSource } from 'ol/source' import { ImageStatic, Vector as VectorSource } from 'ol/source'
@ -14,13 +15,13 @@ import { addInteractions, handleImageDrop, loadFeatures, processFigure, processL
import useSWR, { SWRConfiguration } from 'swr' import useSWR, { SWRConfiguration } from 'swr'
import { fetcher } from '../../http/axiosInstance' import { fetcher } from '../../http/axiosInstance'
import { BASE_URL } from '../../constants' import { BASE_URL } from '../../constants'
import { ActionIcon, Autocomplete, CloseButton, Flex, Select as MantineSelect, MantineStyleProp, rem, useMantineColorScheme, Portal, Menu, Button, Group, Divider, LoadingOverlay, Stack, Container, Modal, Transition } from '@mantine/core' import { ActionIcon, Autocomplete, CloseButton, Flex, Select as MantineSelect, MantineStyleProp, rem, useMantineColorScheme, Portal, Menu, Button, Group, Divider, LoadingOverlay, Stack, Container, Modal, Transition, Text, Select, Switch, Checkbox, Radio, ScrollAreaAutosize } from '@mantine/core'
import { IconBoxMultiple, IconBoxPadding, IconChevronDown, IconPlus, IconSearch, IconUpload } from '@tabler/icons-react' import { IconArrowsMaximize, IconArrowsMinimize, IconBoxMultiple, IconBoxPadding, IconChevronCompactLeft, IconChevronDown, IconChevronLeft, IconChevronsDown, IconHelp, IconPlus, IconSearch, IconUpload, IconWindowMaximize, IconWindowMinimize } from '@tabler/icons-react'
import { ICitySettings, IFigure, ILine } from '../../interfaces/gis' import { ICitySettings, IFigure, ILine } from '../../interfaces/gis'
import axios from 'axios' import axios from 'axios'
import MapToolbar from './MapToolbar/MapToolbar' import MapToolbar from './MapToolbar/MapToolbar'
import MapStatusbar from './MapStatusbar/MapStatusbar' import MapStatusbar from './MapStatusbar/MapStatusbar'
import { setAlignMode, setSatMapsProvider, setTypeRoles, useMapStore, setMapLabel, clearPrintArea } from '../../store/map' import { setAlignMode, setSatMapsProvider, setTypeRoles, useMapStore, setMapLabel, clearPrintArea, setPreviousView, setMode, setPrintScale, PrintScale, setPrintScaleLine } from '../../store/map'
import { useThrottle } from '@uidotdev/usehooks' import { useThrottle } from '@uidotdev/usehooks'
import ObjectTree from '../Tree/ObjectTree' import ObjectTree from '../Tree/ObjectTree'
import { setCurrentObjectId, setSelectedDistrict, setSelectedRegion, setSelectedYear, useObjectsStore } from '../../store/objects' import { setCurrentObjectId, setSelectedDistrict, setSelectedRegion, setSelectedYear, useObjectsStore } from '../../store/objects'
@ -32,13 +33,11 @@ import GeoJSON from 'ol/format/GeoJSON'
import MapLegend from './MapLegend/MapLegend' import MapLegend from './MapLegend/MapLegend'
import GisService from '../../services/GisService' import GisService from '../../services/GisService'
import MapMode from './MapMode' import MapMode from './MapMode'
import ScaleLine from 'ol/control/ScaleLine'
const satMapsProviders = [ import { getPointResolution } from 'ol/proj'
{ label: 'Google', value: 'google' }, import { PrintFormat, PrintOrientation, printResolutions, setPrintOrientation, setPrintResolution, usePrintStore } from '../../store/print'
{ label: 'Яндекс', value: 'yandex' }, import { printDimensions, satMapsProviders, scaleOptions, schemas } from '../../constants/map'
{ label: 'Подложка', value: 'custom' }, import { modals } from "@mantine/modals";
{ label: 'Static', value: 'static' }
]
const swrOptions: SWRConfiguration = { const swrOptions: SWRConfiguration = {
revalidateOnFocus: false revalidateOnFocus: false
@ -73,9 +72,11 @@ const MapComponent = ({
nodeLayerSource, drawingLayerSource, nodeLayerSource, drawingLayerSource,
satLayer, staticMapLayer, figuresLayer, linesLayer, satLayer, staticMapLayer, figuresLayer, linesLayer,
regionsLayer, districtBoundLayer, baseLayer, regionsLayer, districtBoundLayer, baseLayer,
printArea, printSource, printAreaDraw previousView, printArea, printSource, printAreaDraw, printScale, printScaleLine,
} = useMapStore().id[id] } = useMapStore().id[id]
const { printOrientation, printResolution, printFormat } = usePrintStore()
// Tab settings // Tab settings
const objectsPane: ITabsPane[] = [{ title: 'Объекты', value: 'objects', view: <ObjectTree map_id={id} /> }, { title: 'Неразмещенные', value: 'unplaced', view: <></> }, { title: 'Другие', value: 'other', view: <></> },] const objectsPane: ITabsPane[] = [{ title: 'Объекты', value: 'objects', view: <ObjectTree map_id={id} /> }, { title: 'Неразмещенные', value: 'unplaced', view: <></> }, { title: 'Другие', value: 'other', view: <></> },]
const paramsPane: ITabsPane[] = [{ title: 'История изменений', value: 'history', view: <></> }, { title: 'Параметры', value: 'parameters', view: <ObjectParameters map_id={id} /> }, { title: 'Вычисляемые', value: 'calculated', view: <></> }] const paramsPane: ITabsPane[] = [{ title: 'История изменений', value: 'history', view: <></> }, { title: 'Параметры', value: 'parameters', view: <ObjectParameters map_id={id} /> }, { title: 'Вычисляемые', value: 'calculated', view: <></> }]
@ -180,7 +181,6 @@ const MapComponent = ({
zIndex: '1', zIndex: '1',
backgroundColor: colorScheme === 'light' ? '#F0F0F0CC' : '#000000CC', backgroundColor: colorScheme === 'light' ? '#F0F0F0CC' : '#000000CC',
backdropFilter: 'blur(8px)', backdropFilter: 'blur(8px)',
border: '1px solid #00000022'
} }
const { data: nodes } = useSWR('/nodes/all', () => fetcher('/nodes/all', BASE_URL.ems), { revalidateOnFocus: false }) const { data: nodes } = useSWR('/nodes/all', () => fetcher('/nodes/all', BASE_URL.ems), { revalidateOnFocus: false })
@ -205,18 +205,18 @@ const MapComponent = ({
}, [nodes, nodeLayerSource]) }, [nodes, nodeLayerSource])
const [searchObject, setSearchObject] = useState<string | undefined>("") const [searchObject, setSearchObject] = useState<string | undefined>("")
const throttledSearchObject = useThrottle(searchObject, 500); const throttledSearchObject = useThrottle(searchObject, 500)
useEffect(() => { useEffect(() => {
if (!selectedObjectType || !map) return; if (!selectedObjectType || !map) return
if (figuresLayer) { if (figuresLayer) {
// Reset styles and apply highlight to matching features // Reset styles and apply highlight to matching features
figuresLayer.getSource()?.getFeatures().forEach((feature) => { figuresLayer.getSource()?.getFeatures().forEach((feature) => {
if (selectedObjectType == feature.get('type')) { if (selectedObjectType == feature.get('type')) {
feature.setStyle(highlightStyleYellow); feature.setStyle(highlightStyleYellow)
} else { } else {
feature.setStyle(undefined); // Reset to default style feature.setStyle(undefined) // Reset to default style
} }
}) })
} }
@ -225,9 +225,9 @@ const MapComponent = ({
// Reset styles and apply highlight to matching features // Reset styles and apply highlight to matching features
linesLayer.getSource()?.getFeatures().forEach((feature) => { linesLayer.getSource()?.getFeatures().forEach((feature) => {
if (selectedObjectType == feature.get('type')) { if (selectedObjectType == feature.get('type')) {
feature.setStyle(highlightStyleYellow); feature.setStyle(highlightStyleYellow)
} else { } else {
feature.setStyle(undefined); // Reset to default style feature.setStyle(undefined) // Reset to default style
} }
}) })
} }
@ -248,7 +248,7 @@ const MapComponent = ({
} }
} else { } else {
feature.setStyle(undefined); // Reset to default style feature.setStyle(undefined) // Reset to default style
} }
}) })
} }
@ -257,14 +257,14 @@ const MapComponent = ({
// Reset styles and apply highlight to matching features // Reset styles and apply highlight to matching features
linesLayer.getSource()?.getFeatures().forEach((feature: Feature) => { linesLayer.getSource()?.getFeatures().forEach((feature: Feature) => {
if (currentObjectId == feature.get('object_id')) { if (currentObjectId == feature.get('object_id')) {
feature.setStyle(highlightStyleRed); feature.setStyle(highlightStyleRed)
const geometry = feature.getGeometry() const geometry = feature.getGeometry()
if (geometry) { if (geometry) {
map?.getView().fit(geometry as SimpleGeometry, { duration: 500, maxZoom: 18 }) map?.getView().fit(geometry as SimpleGeometry, { duration: 500, maxZoom: 18 })
} }
} else { } else {
feature.setStyle(undefined); // Reset to default style feature.setStyle(undefined) // Reset to default style
} }
}) })
} }
@ -311,79 +311,6 @@ const MapComponent = ({
} }
}, [districtsData, id, selectedDistrict, selectedYear]) }, [districtsData, id, selectedDistrict, selectedYear])
//const [searchParams, setSearchParams] = useSearchParams()
// useEffect(() => {
// if (selectedRegion) {
// setSearchParams((params) => {
// params.set('r', selectedRegion.toString());
// return params
// })
// }
// if (selectedDistrict) {
// setSearchParams((params) => {
// params.set('d', selectedDistrict?.toString());
// return params
// })
// }
// if (selectedYear) {
// setSearchParams((params) => {
// params.set('y', selectedYear?.toString());
// return params
// })
// }
// if (currentObjectId) {
// setSearchParams((params) => {
// params.set('o', currentObjectId?.toString());
// return params
// })
// }
// }, [selectedRegion, selectedDistrict, selectedYear, currentObjectId, setSearchParams])
// useEffect(() => {
// if (Array.isArray(regionsData)) {
// const region = searchParams.get('r')
// if (searchParams.get('r')) {
// setSelectedRegion(Number(region))
// }
// }
// }, [searchParams, regionsData])
// useEffect(() => {
// if (Array.isArray(regionsData)) {
// const district = searchParams.get('d')
// if (Array.isArray(districtsData)) {
// if (district) {
// setSelectedDistrict(Number(district))
// }
// }
// }
// }, [searchParams, regionsData, districtsData])
// useEffect(() => {
// if (Array.isArray(regionsData)) {
// const year = searchParams.get('y')
// if (year) {
// setSelectedYear(Number(year))
// }
// }
// }, [searchParams, regionsData])
// useEffect(() => {
// const object = searchParams.get('o')
// if (figuresData && linesData && object) {
// setCurrentObjectId(object)
// }
// }, [searchParams, figuresData, linesData])
useEffect(() => { useEffect(() => {
if (selectedDistrict === null) { if (selectedDistrict === null) {
setSelectedYear(id, null) setSelectedYear(id, null)
@ -395,6 +322,8 @@ const MapComponent = ({
} }
}, [selectedDistrict, selectedRegion, id]) }, [selectedDistrict, selectedRegion, id])
const [leftPaneHidden, setLeftPaneHidden] = useState(false)
useEffect(() => { useEffect(() => {
const districtBoundSource = districtBoundLayer.getSource() const districtBoundSource = districtBoundLayer.getSource()
@ -456,9 +385,9 @@ const MapComponent = ({
if (selectedDistrict && districtData) { if (selectedDistrict && districtData) {
const settings = getCitySettings() const settings = getCitySettings()
const imageUrl = `${import.meta.env.VITE_API_EMS_URL}/static/${selectedDistrict}`; const imageUrl = `${import.meta.env.VITE_API_EMS_URL}/static/${selectedDistrict}`
const img = new Image(); const img = new Image()
img.src = imageUrl; img.src = imageUrl
img.onload = () => { img.onload = () => {
if (map) { if (map) {
const width = img.naturalWidth const width = img.naturalWidth
@ -469,25 +398,25 @@ const MapComponent = ({
const wk = width * k const wk = width * k
const hk = height * k const hk = height * k
const center = [settings.offset_x + (wk), settings.offset_y - (hk)]; const center = [settings.offset_x + (wk), settings.offset_y - (hk)]
const extent = [ const extent = [
center[0] - (wk), center[0] - (wk),
center[1] - (hk), center[1] - (hk),
center[0] + (wk), center[0] + (wk),
center[1] + (hk), center[1] + (hk),
]; ]
// Set up the initial image layer with the extent // Set up the initial image layer with the extent
const imageSource = new ImageStatic({ const imageSource = new ImageStatic({
url: imageUrl, url: imageUrl,
imageExtent: extent, imageExtent: extent,
}); })
staticMapLayer.setSource(imageSource); staticMapLayer.setSource(imageSource)
//map.current.addLayer(imageLayer.current); //map.current.addLayer(imageLayer.current)
} }
}; }
} }
}, [selectedDistrict, districtData, staticMapLayer]) }, [selectedDistrict, districtData, staticMapLayer])
@ -496,27 +425,33 @@ const MapComponent = ({
baseLayer.on('prerender', function (e) { baseLayer.on('prerender', function (e) {
if (colorScheme === 'dark') { if (colorScheme === 'dark') {
if (e.context) { if (e.context) {
const context = e.context as CanvasRenderingContext2D; const context = e.context as CanvasRenderingContext2D
context.filter = 'grayscale(80%) invert(100%) hue-rotate(180deg) '; context.filter = 'grayscale(80%) invert(100%) hue-rotate(180deg) '
context.globalCompositeOperation = 'source-over'; context.globalCompositeOperation = 'source-over'
} }
} else { } else {
if (e.context) { if (e.context) {
const context = e.context as CanvasRenderingContext2D; const context = e.context as CanvasRenderingContext2D
context.filter = 'none'; context.filter = 'none'
} }
} }
}) })
baseLayer.on('postrender', function (e) { baseLayer.on('postrender', function (e) {
if (e.context) { if (e.context) {
const context = e.context as CanvasRenderingContext2D; const context = e.context as CanvasRenderingContext2D
context.filter = 'none'; context.filter = 'none'
} }
}) })
} }
}, [colorScheme]) }, [colorScheme])
const scaleLine = useRef(new ScaleLine({
bar: true,
text: true,
minWidth: 125
}))
useEffect(() => { useEffect(() => {
if (map) { if (map) {
if (mode === 'print') { if (mode === 'print') {
@ -525,41 +460,199 @@ const MapComponent = ({
map.removeInteraction(printAreaDraw) map.removeInteraction(printAreaDraw)
} }
} }
}, [mode, map, printAreaDraw]) }, [mode, map, printAreaDraw])
useEffect(() => { useEffect(() => {
if (printArea) { if (printArea) {
// backup view before entering print mode
setPreviousView(id, map?.getView())
map?.setTarget('print-portal') map?.setTarget('print-portal')
// map?.setView(new View({
// extent: printArea
// }))
printSource.clear() printSource.clear()
map?.getView().setCenter(getCenter(printArea)) map?.getView().setCenter(getCenter(printArea))
map?.getView().fit(printArea, { map?.getView().fit(printArea, {
size: [640, 320] size: printOrientation === 'horizontal' ? [594, 420] : [420, 594]
}) })
map?.removeInteraction(printAreaDraw)
} }
}, [printArea, map]) }, [printArea, map])
const exportToPDF = (format: PrintFormat, resolution: number, orientation: PrintOrientation) => {
const dim = printDimensions[format]
const width = Math.round((dim[orientation === 'horizontal' ? 0 : 1] * resolution) / 25.4)
const height = Math.round((dim[orientation === 'horizontal' ? 1 : 0] * resolution) / 25.4)
if (!map) return
// Store original size and scale
const originalSize = map.getSize()
const originalResolution = map.getView().getResolution()
if (!originalSize || !originalResolution) return
// Calculate new resolution to fit high DPI
const scaleFactor = width / originalSize[0]
const newResolution = originalResolution / scaleFactor
// console.log(`New resolution: ${newResolution}`)
const center = map.getView().getCenter()
let scaleResolution
if (center) {
scaleResolution = Number(printScale) / getPointResolution(map.getView().getProjection(), Number(resolution) / 25.4, center)
// console.log(`Scaled resolution: ${scaleResolution}`)
}
console.log(width, height)
// Set new high-resolution rendering
map.setSize([width, height])
map.getView().setResolution(newResolution)
map.renderSync()
map.once("rendercomplete", function () {
const mapCanvas = document.createElement("canvas")
mapCanvas.width = width
mapCanvas.height = height
const mapContext = mapCanvas.getContext("2d")
if (!mapContext) return
const canvas = document.querySelector('canvas')
if (canvas) {
if (canvas.width > 0) {
const opacity = canvas.parentElement?.style.opacity || "1"
mapContext.globalAlpha = parseFloat(opacity)
const transform = canvas.style.transform
const matrixMatch = transform.match(/^matrix\(([^)]+)\)$/)
if (matrixMatch) {
const matrix = matrixMatch[1].split(",").map(Number)
mapContext.setTransform(...matrix as [number, number, number, number, number, number])
}
mapContext.drawImage(canvas, 0, 0)
}
}
mapContext.globalAlpha = 1
mapContext.setTransform(1, 0, 0, 1, 0, 0)
// Restore original map settings
map.setSize(originalSize)
map.getView().setResolution(originalResolution)
map.renderSync()
// Generate PDF
const pdf = new jsPDF(orientation === 'horizontal' ? "landscape" : 'portrait', undefined, format)
pdf.addImage(mapCanvas.toDataURL("image/jpeg"), "JPEG", 0, 0, orientation === 'horizontal' ? dim[0] : dim[1], orientation === 'horizontal' ? dim[1] : dim[0])
const filename = `${selectedYear}-${selectedRegion}-${selectedDistrict}-${new Date().toISOString()}.pdf`
pdf.save(filename, {
returnPromise: true
}).then(() => {
})
})
}
useEffect(() => {
if (printScaleLine) {
map?.addControl(scaleLine.current)
} else {
map?.removeControl(scaleLine.current)
}
}, [printScaleLine])
const [fullscreen, setFullscreen] = useState(false)
return ( return (
<> <>
<Modal keepMounted size='auto' opened={!!printArea} onClose={() => { <Modal.Root scrollAreaComponent={ScrollAreaAutosize} keepMounted size='auto' opened={!!printArea} onClose={() => {
clearPrintArea(id) clearPrintArea(id)
map?.setTarget(mapElement.current as HTMLDivElement) map?.setTarget(mapElement.current as HTMLDivElement)
}} title="Предпросмотр области"> map?.addInteraction(printAreaDraw)
<Stack> }} fullScreen={fullscreen}>
<div id='print-portal' style={{ width: '640px', height: '320px' }}> <Modal.Overlay />
<Modal.Content style={{ transition: 'all .3s ease' }}>
<Modal.Header>
<Modal.Title>
Предпросмотр области печати
</Modal.Title>
</div> <Flex ml='auto' gap='md'>
<Flex gap='sm'> <ActionIcon title='Помощь' ml='auto' variant='transparent'>
<Button> <IconHelp color='gray' />
Печать </ActionIcon>
</Button> <ActionIcon title={fullscreen ? 'Свернуть' : 'Развернуть'} variant='transparent' onClick={() => setFullscreen(!fullscreen)}>
</Flex> {fullscreen ? <IconWindowMinimize color='gray' /> : <IconWindowMaximize color='gray' />}
</ActionIcon>
<Modal.CloseButton title='Закрыть' />
</Flex>
</Modal.Header>
<Modal.Body>
<Stack align='center'>
<Text w='100%'>Область печати можно передвигать.</Text>
</Stack> <div id='print-portal' style={{
</Modal> width: printOrientation === 'horizontal' ? '594px' : '420px',
height: printOrientation === 'horizontal' ? '420px' : '594px'
}}>
</div>
<Flex w='100%' wrap='wrap' gap='lg' justify='space-between'>
<Radio.Group
label='Ориентация'
value={printOrientation}
onChange={(value) => setPrintOrientation(value as PrintOrientation)}
>
<Stack>
<Radio value='horizontal' label='Горизонтальная' />
<Radio value='vertical' label='Вертикальная' />
</Stack>
</Radio.Group>
<Select
allowDeselect={false}
label="Разрешение"
placeholder="Выберите разрешение"
data={printResolutions}
value={printResolution.toString()}
onChange={(value) => setPrintResolution(Number(value))}
/>
<Select
allowDeselect={false}
label="Масштаб"
placeholder="Выберите масштаб"
data={scaleOptions}
value={printScale}
onChange={(value) => setPrintScale(id, value as PrintScale)}
/>
<Checkbox
checked={printScaleLine}
label="Масштабная линия"
onChange={(event) => setPrintScaleLine(id, event.currentTarget.checked)}
/>
</Flex>
<Flex w='100%' gap='sm' align='center'>
<Button ml='auto' onClick={() => {
if (previousView) {
exportToPDF(printFormat, printResolution, printOrientation)
}
}}>
Печать
</Button>
</Flex>
</Stack>
</Modal.Body>
</Modal.Content>
</Modal.Root>
{active && {active &&
<Portal target='#header-portal'> <Portal target='#header-portal'>
@ -594,13 +687,7 @@ const MapComponent = ({
data={regionsData ? regionsData.map((item: { name: string, id: number }) => ({ label: item.name, value: item.id.toString() })) : []} data={regionsData ? regionsData.map((item: { name: string, id: number }) => ({ label: item.name, value: item.id.toString() })) : []}
onChange={(value) => setSelectedRegion(id, Number(value))} onChange={(value) => setSelectedRegion(id, Number(value))}
clearable clearable
onClear={() => { onClear={() => setSelectedRegion(id, null)}
setSelectedRegion(id, null)
// setSearchParams((params) => {
// params.delete('r')
// return params
// })
}}
searchable searchable
value={selectedRegion ? selectedRegion.toString() : null} value={selectedRegion ? selectedRegion.toString() : null}
/> />
@ -611,19 +698,13 @@ const MapComponent = ({
data={districtsData ? districtsData.map((item: { name: string, id: number, district_name: string }) => ({ label: [item.name, item.district_name].join(' - '), value: item.id.toString() })) : []} data={districtsData ? districtsData.map((item: { name: string, id: number, district_name: string }) => ({ label: [item.name, item.district_name].join(' - '), value: item.id.toString() })) : []}
onChange={(value) => setSelectedDistrict(id, Number(value))} onChange={(value) => setSelectedDistrict(id, Number(value))}
clearable clearable
onClear={() => { onClear={() => { setSelectedDistrict(id, null) }}
setSelectedDistrict(id, null)
// setSearchParams((params) => {
// params.delete('d')
// return params
// })
}}
searchable searchable
value={selectedDistrict ? selectedDistrict.toString() : null} value={selectedDistrict ? selectedDistrict.toString() : null}
/> />
<MantineSelect placeholder='Схема' w='92px' <MantineSelect placeholder='Схема' w='92px'
data={['2018', '2019', '2020', '2021', '2022', '2023', '2024'].map(el => ({ label: el, value: el }))} data={schemas.map(el => ({ label: el, value: el }))}
onChange={(e) => { onChange={(e) => {
if (e) { if (e) {
setSelectedYear(id, Number(e)) setSelectedYear(id, Number(e))
@ -631,13 +712,7 @@ const MapComponent = ({
setSelectedYear(id, null) setSelectedYear(id, null)
} }
}} }}
onClear={() => { onClear={() => setSelectedYear(id, null)}
setSelectedYear(id, null)
// setSearchParams((params) => {
// params.delete('y')
// return params
// })
}}
value={selectedYear ? selectedYear?.toString() : null} value={selectedYear ? selectedYear?.toString() : null}
clearable clearable
/> />
@ -682,19 +757,22 @@ const MapComponent = ({
<Container pos='absolute' w='100%' h='100%' p='0' fluid> <Container pos='absolute' w='100%' h='100%' p='0' fluid>
<Flex direction='column' w='100%' h='100%'> <Flex direction='column' w='100%' h='100%'>
<Flex w='100%' h='94%' p='xs' style={{ flexGrow: 1 }}> <Flex w='100%' h='94%' p='xs' style={{ flexGrow: 1 }}>
<Stack w='100%' maw='340px'> <Stack w='100%' maw='380px'>
<Transition <Flex w='100%' h='100%' gap='xs'>
mounted={!!selectedRegion && !!selectedDistrict && !!selectedYear} {selectedRegion && selectedDistrict && selectedYear &&
transition="slide-right" <Flex direction='column' h={'100%'} w={leftPaneHidden ? '0px' : '100%'} style={{ ...mapControlsStyle, transition: 'width .3s ease' }}>
duration={200} <TabsPane defaultTab='objects' tabs={objectsPane} />
timingFunction="ease" <Divider />
> <TabsPane defaultTab='parameters' tabs={paramsPane} />
{(styles) => <Flex direction='column' h={'100%'} w={'100%'} style={{ ...mapControlsStyle, ...styles }}> </Flex>
<TabsPane defaultTab='objects' tabs={objectsPane} /> }
<Divider />
<TabsPane defaultTab='parameters' tabs={paramsPane} /> {!!selectedRegion && !!selectedDistrict && !!selectedYear &&
</Flex>} <Button p='0' variant='subtle' w='32' style={{ zIndex: '1' }} onClick={() => setLeftPaneHidden(!leftPaneHidden)}>
</Transition> <IconChevronLeft size={16} style={{ transform: `${leftPaneHidden ? 'rotate(180deg)' : ''}` }} />
</Button>
}
</Flex>
</Stack> </Stack>
<Stack w='100%' align='center'> <Stack w='100%' align='center'>
@ -735,6 +813,6 @@ const MapComponent = ({
<LoadingOverlay visible={linesValidating || figuresValidating} /> <LoadingOverlay visible={linesValidating || figuresValidating} />
</> </>
) )
}; }
export default MapComponent export default MapComponent

View File

@ -1,40 +1,106 @@
import { Center, SegmentedControl } from '@mantine/core' import { Button, Flex, FloatingIndicator, Popover, SegmentedControl } from '@mantine/core'
import { getMode, Mode, setMode } from '../../store/map' import { Mode, setMode, useMapStore } from '../../store/map'
import { IconEdit, IconEye, IconPrinter } from '@tabler/icons-react' import { IconChevronDown, IconCropLandscape, IconCropPortrait, IconEdit, IconEye, IconPrinter } from '@tabler/icons-react'
import { useEffect, useState } from 'react'
import { PrintOrientation, setPrintOrientation, usePrintStore } from '../../store/print'
const MapMode = ({ const MapMode = ({
map_id map_id
}: { map_id: string }) => { }: { map_id: string }) => {
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
const [controlsRefs, setControlsRefs] = useState<Record<string, HTMLButtonElement | null>>({});
const { mode } = useMapStore().id[map_id]
const setControlRef = (item: Mode) => (node: HTMLButtonElement) => {
controlsRefs[item] = node;
setControlsRefs(controlsRefs);
}
const { printOrientation } = usePrintStore()
useEffect(() => {
}, [printOrientation])
return ( return (
<SegmentedControl value={getMode(map_id)} onChange={(value) => setMode(map_id, value as Mode)} color="blue" w='auto' data={[ <Flex ref={setRootRef} p={4} gap={4}>
{ <Button
value: 'view', variant={mode === 'view' ? 'filled' : 'subtle'}
label: ( key={'view'}
<Center style={{ gap: 10 }}> ref={setControlRef('view' as Mode)}
<IconEye size={16} /> onClick={() => {
<span>Просмотр</span> setMode(map_id, 'view' as Mode)
</Center> }}
), leftSection={<IconEye size={16} />}
}, mod={{ active: mode === 'view' as Mode }}
{ >
value: 'edit', Просмотр
label: ( </Button>
<Center style={{ gap: 10 }}>
<IconEdit size={16} /> <Button
<span>Редактирование</span> variant={mode === 'edit' ? 'filled' : 'subtle'}
</Center> key={'edit'}
), ref={setControlRef('edit' as Mode)}
}, onClick={() => {
{ setMode(map_id, 'edit' as Mode)
value: 'print', }}
label: ( leftSection={<IconEdit size={16} />}
<Center style={{ gap: 10 }}> mod={{ active: mode === 'edit' as Mode }}
<IconPrinter size={16} /> >
<span>Печать</span> Редактирование
</Center> </Button>
),
}, <Popover width='auto' position='bottom-end' >
]} /> <Popover.Target>
<Button.Group>
<Button
variant={mode === 'print' ? 'filled' : 'subtle'}
key={'print'}
ref={setControlRef('print' as Mode)}
onClick={(e) => {
e.stopPropagation()
setMode(map_id, 'print' as Mode)
}}
leftSection={<IconPrinter size={16} />}
mod={{ active: mode === 'print' as Mode }}
>
Печать
</Button>
<Button variant={mode === 'print' ? 'filled' : 'subtle'} w='auto' p={8} title='Ориентация'>
<IconChevronDown size={16} />
</Button>
</Button.Group>
</Popover.Target>
<Popover.Dropdown p={0} style={{ display: 'flex' }}>
<SegmentedControl
color='blue'
value={printOrientation}
onChange={(value) => {
setPrintOrientation(value as PrintOrientation)
setMode(map_id, 'print' as Mode)
}}
data={[
{
value: 'horizontal',
label: (
<IconCropLandscape title='Горизонтальная' style={{ display: 'block' }} size={20} />
),
},
{
value: 'vertical',
label: (
<IconCropPortrait title='Вертикальная' style={{ display: 'block' }} size={20} />
),
},
]}
/>
</Popover.Dropdown>
</Popover>
<FloatingIndicator target={controlsRefs[mode]} parent={rootRef} />
</Flex >
) )
} }

View File

@ -15,9 +15,9 @@ import { ImageStatic } from "ol/source";
import { IFigure, ILine } from "../../interfaces/gis"; import { IFigure, ILine } from "../../interfaces/gis";
import { fromCircle, fromExtent } from "ol/geom/Polygon"; import { fromCircle, fromExtent } from "ol/geom/Polygon";
import { measureStyleFunction, modifyStyle } from "./Measure/MeasureStyles"; import { measureStyleFunction, modifyStyle } from "./Measure/MeasureStyles";
import { getCurrentTool, getDraw, getDrawingLayerSource, getImageLayer, getMap, getMeasureClearPrevious, getMeasureDraw, getMeasureModify, getMeasureSource, getMeasureType, getOverlayLayerSource, getSnap, getTipPoint, getTranslate, setDraw, setFile, setMeasureDraw, setPolygonExtent, setRectCoords, setSnap, setTranslate } from "../../store/map"; import { getCurrentTool, getDraw, getDrawingLayerSource, getImageLayer, getMap, getMeasureClearPrevious, getMeasureDraw, getMeasureModify, getMeasureSource, getMeasureType, getOverlayLayerSource, getSnap, getTipPoint, getTranslate, PrintOrientation, setDraw, setFile, setMeasureDraw, setPolygonExtent, setRectCoords, setSnap, setTranslate } from "../../store/map";
import Collection from "ol/Collection"; import Collection from "ol/Collection";
import { GeometryFunction, SketchCoordType } from "ol/interaction/Draw"; import { SketchCoordType } from "ol/interaction/Draw";
const calculateAngle = (coords: [number, number][]) => { const calculateAngle = (coords: [number, number][]) => {
const [start, end] = coords; const [start, end] = coords;
@ -342,9 +342,10 @@ export const updateImageSource = (
} }
}; };
export const fixedAspectRatioBox: GeometryFunction = ( export const fixedAspectRatioBox = (
coordinates: SketchCoordType, coordinates: SketchCoordType,
geometry: SimpleGeometry | undefined, geometry: SimpleGeometry | undefined,
orientation?: PrintOrientation
): SimpleGeometry => { ): SimpleGeometry => {
if (!Array.isArray(coordinates) || coordinates.length < 2) { if (!Array.isArray(coordinates) || coordinates.length < 2) {
return geometry ?? new Polygon([]); return geometry ?? new Polygon([]);
@ -356,7 +357,7 @@ export const fixedAspectRatioBox: GeometryFunction = (
const maxX = end[0]; const maxX = end[0];
const width = maxX - minX; const width = maxX - minX;
const height = Math.abs(width / 2) const height = orientation === 'horizontal' ? Math.abs(width / Math.sqrt(2)) : Math.abs(width * Math.sqrt(2)); // Maintain 1:√2 ratio
const maxY = end[1] > minY ? minY + height : minY - height; const maxY = end[1] > minY ? minY + height : minY - height;

View File

@ -0,0 +1,48 @@
export const schemas = [
'2018',
'2019',
'2020',
'2021',
'2022',
'2023',
'2024',
]
export const scaleOptions = [
{
label: '1:500000',
value: '500'
},
{
label: '1:100000',
value: '250'
},
{
label: '1:50000',
value: '50'
},
{
label: '1:25000',
value: '25'
},
{
label: '1:10000',
value: '10'
},
]
export const satMapsProviders = [
{ label: 'Google', value: 'google' },
{ label: 'Яндекс', value: 'yandex' },
{ label: 'Подложка', value: 'custom' },
{ label: 'Static', value: 'static' }
]
export const printDimensions = {
a0: [1189, 841],
a1: [841, 594],
a2: [594, 420],
a3: [420, 297],
a4: [297, 210],
a5: [210, 148],
}

View File

@ -25,9 +25,14 @@ import { transform } from 'ol/proj';
import { applyTransformations, calculateTransformations, fixedAspectRatioBox, zoomToFeature } from '../components/map/mapUtils'; import { applyTransformations, calculateTransformations, fixedAspectRatioBox, zoomToFeature } from '../components/map/mapUtils';
import { setCurrentObjectId, setSelectedRegion } from './objects'; import { setCurrentObjectId, setSelectedRegion } from './objects';
import View from 'ol/View'; import View from 'ol/View';
import { getPrintOrientation } from './print';
export type Mode = 'edit' | 'view' | 'print' export type Mode = 'edit' | 'view' | 'print'
export type PrintScale = '500' | '250' | '50' | '25' | '10'
export type PrintOrientation = 'horizontal' | 'vertical'
interface MapState { interface MapState {
id: Record<string, { id: Record<string, {
currentTool: ToolType; currentTool: ToolType;
@ -77,11 +82,17 @@ interface MapState {
overlayLayer: VectorLayer; overlayLayer: VectorLayer;
regionSelect: Select; regionSelect: Select;
lineSelect: Select; lineSelect: Select;
previousView: View | undefined | null;
printArea: Extent | null; printArea: Extent | null;
printLayer: VectorLayer; printLayer: VectorLayer;
printSource: VectorSource; printSource: VectorSource;
printAreaDraw: Draw; printAreaDraw: Draw;
printPreviewSize: number[]; printPreviewSize: number[];
printDim: 'a0' | 'a1' | 'a2' | 'a3' | 'a4' | 'a5';
printRes: number;
printOrientation: PrintOrientation;
printScale: PrintScale;
printScaleLine: boolean;
}>; }>;
} }
@ -224,7 +235,22 @@ export const initializeMapState = (
const printAreaDraw = new Draw({ const printAreaDraw = new Draw({
source: printSource, source: printSource,
type: 'Circle', type: 'Circle',
geometryFunction: fixedAspectRatioBox style: (feature) => {
return new Style({
fill: new Fill({
color: "rgba(0, 0, 255, 0.3)", // Semi-transparent blue fill
}),
stroke: new Stroke({
color: "blue",
width: 2,
}),
image: undefined, // 🚀 This removes the default point!
});
},
geometryFunction: function (coords, geom) {
const printOrientation = getPrintOrientation()
return fixedAspectRatioBox(coords, geom, printOrientation)
},
}) })
printAreaDraw.on('drawend', (e) => { printAreaDraw.on('drawend', (e) => {
@ -396,17 +422,59 @@ export const initializeMapState = (
overlayLayer: overlayLayer, overlayLayer: overlayLayer,
regionSelect: regionSelect, regionSelect: regionSelect,
lineSelect: lineSelect, lineSelect: lineSelect,
previousView: null,
printArea: null, printArea: null,
printLayer: printLayer, printLayer: printLayer,
printSource: printSource, printSource: printSource,
printAreaDraw: printAreaDraw, printAreaDraw: printAreaDraw,
printPreviewSize: [640, 320] printPreviewSize: [640, 320],
printDim: 'a4',
printOrientation: 'horizontal',
printRes: 72,
printScale: '250',
printScaleLine: true
} }
} }
} }
}) })
} }
export const setPrintOrientation = (id: string, orientation: PrintOrientation) => useMapStore.setState((state) => {
return {
id: {
...state.id,
[id]: { ...state.id[id], printOrientation: orientation }
}
}
})
export const setPrintScaleLine = (id: string, bool: boolean) => useMapStore.setState((state) => {
return {
id: {
...state.id,
[id]: { ...state.id[id], printScaleLine: bool }
}
}
})
export const setPrintScale = (id: string, printScale: PrintScale) => useMapStore.setState((state) => {
return {
id: {
...state.id,
[id]: { ...state.id[id], printScale: printScale }
}
}
})
export const setPreviousView = (id: string, view: View | undefined | null) => useMapStore.setState((state) => {
return {
id: {
...state.id,
[id]: { ...state.id[id], previousView: view }
}
}
})
export const clearPrintArea = (id: string) => useMapStore.setState((state) => { export const clearPrintArea = (id: string) => useMapStore.setState((state) => {
state.id[id].printSource.clear() state.id[id].printSource.clear()

23
client/src/store/print.ts Normal file
View File

@ -0,0 +1,23 @@
import { create } from 'zustand';
export type PrintOrientation = 'horizontal' | 'vertical'
export type PrintFormat = 'a0' | 'a1' | 'a2' | 'a3' | 'a4' | 'a5'
export const printResolutions = ['72', '150', '200', '300']
export interface PrintState {
printFormat: PrintFormat;
printOrientation: PrintOrientation;
printResolution: number;
}
export const usePrintStore = create<PrintState>(() => ({
printFormat: 'a4',
printOrientation: 'horizontal',
printResolution: 72
}))
export const getPrintOrientation = () => usePrintStore.getState().printOrientation
export const setPrintFormat = (format: PrintFormat) => usePrintStore.setState(() => ({ printFormat: format }))
export const setPrintOrientation = (orientation: PrintOrientation) => usePrintStore.setState(() => ({ printOrientation: orientation }))
export const setPrintResolution = (resolution: number) => usePrintStore.setState(() => ({ printResolution: resolution }))