From 2ccb6925bdea557e05370e177162953441136ff5 Mon Sep 17 00:00:00 2001 From: pprevautel Date: Tue, 23 Jul 2024 08:36:05 +0200 Subject: [PATCH] feat: maquette => edition guichet (description et zoom) #324 #327 --- assets/@types/app_espaceco.ts | 6 + assets/@types/espaceco.ts | 4 + .../components/Input/AutocompleteSelect.tsx | 1 - assets/components/Utils/ZoomRange.tsx | 60 ++++- assets/espaceco/api/grid.ts | 25 ++ assets/espaceco/api/index.ts | 2 + .../pages/communities/ManageCommunityTr.ts | 47 ++++ .../pages/communities/SearchCommunity.tsx | 1 + .../communities/management/CommunityLogo.tsx | 2 +- .../pages/communities/management/Search.tsx | 80 ------ .../pages/communities/management/SearchTr.tsx | 15 ++ .../management/ZoomAndCentering.tsx | 141 +++++++++-- .../ZoomAndCentering/ExtentDialog.tsx | 232 ++++++++++++++++++ .../management/ZoomAndCentering/RMap.tsx | 119 +++++++++ .../management/ZoomAndCentering/Search.tsx | 64 +++++ .../ZoomAndCentering/SearchGrids.tsx | 62 +++++ .../communities/management/validationTr.tsx | 15 ++ assets/i18n/Common.tsx | 3 + assets/i18n/i18n.ts | 2 +- assets/i18n/languages/en.tsx | 2 +- assets/i18n/languages/fr.tsx | 2 +- assets/img/punaise.png | Bin 0 -> 1454 bytes assets/modules/espaceco/RQKeys.ts | 1 + assets/ol/controls/DisplayCenterControl.ts | 47 ++++ assets/sass/components/zoom-range.scss | 16 +- assets/sass/pages/espaceco/drawcenter.scss | 26 ++ src/Controller/EspaceCo/GridController.php | 46 ++++ src/Services/EspaceCoApi/GridApiService.php | 35 +++ 28 files changed, 931 insertions(+), 125 deletions(-) create mode 100644 assets/espaceco/api/grid.ts delete mode 100644 assets/espaceco/pages/communities/management/Search.tsx create mode 100644 assets/espaceco/pages/communities/management/SearchTr.tsx create mode 100644 assets/espaceco/pages/communities/management/ZoomAndCentering/ExtentDialog.tsx create mode 100644 assets/espaceco/pages/communities/management/ZoomAndCentering/RMap.tsx create mode 100644 assets/espaceco/pages/communities/management/ZoomAndCentering/Search.tsx create mode 100644 assets/espaceco/pages/communities/management/ZoomAndCentering/SearchGrids.tsx create mode 100644 assets/img/punaise.png create mode 100644 assets/ol/controls/DisplayCenterControl.ts create mode 100644 assets/sass/pages/espaceco/drawcenter.scss create mode 100644 src/Controller/EspaceCo/GridController.php create mode 100644 src/Services/EspaceCoApi/GridApiService.php diff --git a/assets/@types/app_espaceco.ts b/assets/@types/app_espaceco.ts index 65394f35..0844a267 100644 --- a/assets/@types/app_espaceco.ts +++ b/assets/@types/app_espaceco.ts @@ -40,3 +40,9 @@ export type SearchResult = { status: string; results: (Address | Poi)[]; }; + +export type SearchGridFilters = { + searchBy?: ("name" | "title")[]; + fields?: ("name" | "title" | "type" | "extent" | "deleted")[]; + adm?: boolean; +}; diff --git a/assets/@types/espaceco.ts b/assets/@types/espaceco.ts index 6243f9e2..5cd1ee4e 100644 --- a/assets/@types/espaceco.ts +++ b/assets/@types/espaceco.ts @@ -10,6 +10,9 @@ export interface CommunityResponseDTO { default_comment: string | null; position: string | null; zoom: number; + zoom_min: number | null; + zoom_max: number | null; + extent: number[] | null; all_members_can_valid: boolean; open_without_affiliation: boolean; open_with_email?: string[]; @@ -27,6 +30,7 @@ export interface Grids { title: string; type: string; deleted: boolean; + extent: number[]; } export interface CommunityPatchDTO extends Partial> { diff --git a/assets/components/Input/AutocompleteSelect.tsx b/assets/components/Input/AutocompleteSelect.tsx index 45cd3503..73018272 100644 --- a/assets/components/Input/AutocompleteSelect.tsx +++ b/assets/components/Input/AutocompleteSelect.tsx @@ -76,7 +76,6 @@ const AutocompleteSelect = (props: AutocompleteSelectProps) => { {label} {hintText && {hintText}} - void; center?: number[]; @@ -28,7 +32,7 @@ type ZoomRangeProps = { const ZoomRange: FC = (props) => { const { data: capabilities } = useCapabilities(); - const { min, max, values, center = olDefaults.center, onChange } = props; + const { label, hintText, min, max, disableSlider, values, onChange, small = false, center = olDefaults.center } = props; // References sur les deux cartes const leftMapRef = useRef(); @@ -91,18 +95,20 @@ const ZoomRange: FC = (props) => { useEffect(() => { if (leftMapTargetRef.current) { - leftMapRef.current = createMap(leftMapTargetRef.current, olDefaults.zoom_levels.TOP); + const minValue = olDefaults.zoom_levels.TOP < min ? min : olDefaults.zoom_levels.TOP; + leftMapRef.current = createMap(leftMapTargetRef.current, minValue); } if (rightMapTargetRef.current) { - rightMapRef.current = createMap(rightMapTargetRef.current, olDefaults.zoom_levels.BOTTOM); + const maxValue = olDefaults.zoom_levels.BOTTOM > max ? max : olDefaults.zoom_levels.BOTTOM; + rightMapRef.current = createMap(rightMapTargetRef.current, maxValue); } return () => { leftMapRef.current?.setTarget(undefined); rightMapRef.current?.setTarget(undefined); }; - }, [createMap]); + }, [min, max, createMap]); useEffect(() => { leftMapRef.current?.getView().setZoom(values[0]); @@ -114,11 +120,43 @@ const ZoomRange: FC = (props) => { return (
-
-
-
+ {label && ( + + )} +
+
+
- onChange(newValues)} /> + { + const v = values; + v[0] = Number(e.currentTarget.value); + onChange(v); + }, + }, + { + value: values[1], + onChange: (e) => { + const v = values; + v[1] = Number(e.currentTarget.value); + onChange(v); + }, + }, + ]} + />
); }; diff --git a/assets/espaceco/api/grid.ts b/assets/espaceco/api/grid.ts new file mode 100644 index 00000000..c0e7bf46 --- /dev/null +++ b/assets/espaceco/api/grid.ts @@ -0,0 +1,25 @@ +import { GetResponse, SearchGridFilters } from "../../@types/app_espaceco"; +import { Grids } from "../../@types/espaceco"; +import { jsonFetch } from "../../modules/jsonFetch"; +import SymfonyRouting from "../../modules/Routing"; + +const search = (text: string, filters: SearchGridFilters, otherOptions: RequestInit = {}) => { + const queryParams = { text: `${text}%` }; + ["searchBy", "fields"].forEach((p) => { + if (filters[p] !== undefined) { + queryParams[p] = filters[p].join(","); + } + }); + if (filters.adm !== undefined) { + queryParams["adm"] = new Boolean(filters.adm).toString(); + } + + const url = SymfonyRouting.generate("cartesgouvfr_api_espaceco_grid_search", queryParams); + return jsonFetch>(url, { + ...otherOptions, + }); +}; + +const grid = { search }; + +export default grid; diff --git a/assets/espaceco/api/index.ts b/assets/espaceco/api/index.ts index e206c40a..eab915ee 100644 --- a/assets/espaceco/api/index.ts +++ b/assets/espaceco/api/index.ts @@ -1,7 +1,9 @@ import community from "./community"; +import grid from "./grid"; const api = { community, + grid, }; export default api; diff --git a/assets/espaceco/pages/communities/ManageCommunityTr.ts b/assets/espaceco/pages/communities/ManageCommunityTr.ts index 43bc9e60..6114b03b 100644 --- a/assets/espaceco/pages/communities/ManageCommunityTr.ts +++ b/assets/espaceco/pages/communities/ManageCommunityTr.ts @@ -26,6 +26,21 @@ export const { i18n } = declareComponentKeys< | "modal.logo.title" | "modal.logo.file_hint" | "desc.keywords" + | "zoom.consistant_error" + | "zoom.position" + | "zoom.position_hint" + | "zoom.zoom_range" + | "zoom.zoom_range_hint" + | "zoom.manage_extent" + | "zoom.extent" + | "zoom.extent_hint" + | "zoom.choice.autocomplete" + | "zoom.choice.manual" + | "zoom.extent_enter_manually" + | "zoom.xmin" + | "zoom.xmax" + | "zoom.ymin" + | "zoom.ymax" >()("ManageCommunity"); export const ManageCommunityFrTranslations: Translations<"fr">["ManageCommunity"] = { @@ -59,6 +74,23 @@ export const ManageCommunityFrTranslations: Translations<"fr">["ManageCommunity" "modal.logo.title": "Logo du guichet", "modal.logo.file_hint": "Taille maximale : 5 Mo. Formats acceptés : jpg, png", "desc.keywords": "Mots-clés (optionnel)", + "zoom.consistant_error": "Emprise et position ne sont pas cohérents", + "zoom.position": "Position", + "zoom.position_hint": "Fixer la position et définissez le niveau de zoom (utilisez votre souris ou la barre de recherche ci-dessous", + "zoom.zoom_range": "Gérer les niveaux de zoom minimum et maximum permis (optionnel)", + "zoom.zoom_range_hint": + "Lorem ipsum dolor sit amet consectetur, adipisicing elit. Libero quisquam hic veritatis, ex ipsum illo labore sint perspiciatis quidem architecto!", + "zoom.manage_extent": "Gérer les bornes de navigation (optionnel)", + "zoom.extent": "Bornes de navigation", + "zoom.extent_hint": + "Lorem ipsum dolor sit amet consectetur adipisicing elit. Maxime vitae maiores suscipit tempore sequi reiciendis nulla optio doloremque! Unde, illo nemo ab accusantium fugiat minus? Natus inventore dolore velit, nostrum dolores molestiae sint laborum, obcaecati, ullam provident repellat consectetur accusamus sunt rerum nobis sequi? Sed maxime fugit dolore! Ipsam, veritatis.", + "zoom.choice.autocomplete": "Recherche d'une emprise administrative", + "zoom.choice.manual": "Saisie manuelle", + "zoom.extent_enter_manually": "Entrer les coordonnées (lon,lat)", + "zoom.xmin": "X min", + "zoom.xmax": "X max", + "zoom.ymin": "Y min", + "zoom.ymax": "Y max", }; export const ManageCommunityEnTranslations: Translations<"en">["ManageCommunity"] = { @@ -92,4 +124,19 @@ export const ManageCommunityEnTranslations: Translations<"en">["ManageCommunity" "modal.logo.title": undefined, "modal.logo.file_hint": undefined, "desc.keywords": undefined, + "zoom.consistant_error": undefined, + "zoom.position": "Position", + "zoom.position_hint": undefined, + "zoom.zoom_range": undefined, + "zoom.zoom_range_hint": undefined, + "zoom.manage_extent": undefined, + "zoom.extent": undefined, + "zoom.extent_hint": undefined, + "zoom.choice.autocomplete": undefined, + "zoom.choice.manual": undefined, + "zoom.extent_enter_manually": undefined, + "zoom.xmin": "X min", + "zoom.xmax": "X max", + "zoom.ymin": "Y min", + "zoom.ymax": "Y max", }; diff --git a/assets/espaceco/pages/communities/SearchCommunity.tsx b/assets/espaceco/pages/communities/SearchCommunity.tsx index ac2128d6..e35b20d7 100644 --- a/assets/espaceco/pages/communities/SearchCommunity.tsx +++ b/assets/espaceco/pages/communities/SearchCommunity.tsx @@ -39,6 +39,7 @@ const SearchCommunity: FC = ({ filter, onChange }) => { noOptionsText={t("no_options")} getOptionLabel={(option) => option.name} options={searchQuery.data || []} + filterOptions={(x) => x} renderInput={(params) => ( = ({ communityId, logoUrl }) => { priority="tertiary no outline" onClick={AddLogoModal.open} /> - {logoUrl !== null && ( + {isValid && ( +
+
+
+ { + const v = { + position: toLonLat(center), + }; + if (zoom) { + v["zoom"] = zoom; + } + setValues({ ...values, ...v }); + }} + /> +
+ ExtentDialogModal.close()} + onApply={(e) => { + setValues({ ...values, extent: e }); + ExtentDialogModal.close(); }} - />{" "} + />
-
); }; diff --git a/assets/espaceco/pages/communities/management/ZoomAndCentering/ExtentDialog.tsx b/assets/espaceco/pages/communities/management/ZoomAndCentering/ExtentDialog.tsx new file mode 100644 index 00000000..edb8254e --- /dev/null +++ b/assets/espaceco/pages/communities/management/ZoomAndCentering/ExtentDialog.tsx @@ -0,0 +1,232 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import Input from "@codegouvfr/react-dsfr/Input"; +import { createModal } from "@codegouvfr/react-dsfr/Modal"; +import RadioButtons from "@codegouvfr/react-dsfr/RadioButtons"; +import { yupResolver } from "@hookform/resolvers/yup"; +// import { Extent } from "ol/extent"; +import { FC, useState } from "react"; +import { createPortal } from "react-dom"; +import { useForm } from "react-hook-form"; +import * as yup from "yup"; +// import { SearchGridFilters } from "../../../../../@types/app_espaceco"; +import Skeleton from "../../../../../components/Utils/Skeleton"; +import { useTranslation } from "../../../../../i18n/i18n"; +import { Extent } from "ol/extent"; +// import SearchGrids from "./SearchGrids"; + +type ExtentDialogProps = { + onCancel: () => void; + onApply: (extent: Extent) => void; +}; + +const ExtentDialogModal = createModal({ + id: "extent-modal", + isOpenedByDefault: false, +}); + +type SearchOption = "autocomplete" | "manual"; +type FieldName = "xmin" | "xmax" | "ymin" | "ymax"; + +/* const filters: SearchGridFilters = { + searchBy: ["name", "title"], + fields: ["name", "title", "extent"], + adm: true, +}; */ + +const transform = (value, origin) => (origin === "" ? undefined : value); + +const ExtentDialog: FC = ({ onCancel, onApply }) => { + const { t: tCommon } = useTranslation("Common"); + const { t: tValid } = useTranslation("ManageCommunityValidations"); + const { t } = useTranslation("ManageCommunity"); + + const [choice, setChoice] = useState("manual"); + + const schema = yup.object({ + xmin: yup + .number() + .typeError(tValid("zoom.extent.nan", { field: "${path}" })) + .min(-180, tValid("zoom.greater_than", { field: "${path}", v: -180 })) + .max(180, tValid("zoom.less_than", { field: "${path}", v: 180 })) + .required(tValid("zoom.extent.mandatory", { field: "${path}" })) + .transform(transform) + .test({ + name: "xmin_check", + message: tValid("zoom.f1_less_than_f2", { field1: "xmin", field2: "xmax" }), + test: (value, context) => { + const xmax = context.parent.xmax; + return xmax !== undefined ? value < xmax : true; + }, + }), + ymin: yup + .number() + .typeError(tValid("zoom.extent.nan", { field: "${path}" })) + .min(-90, tValid("zoom.greater_than", { field: "${path}", v: -90 })) + .max(90, tValid("zoom.less_than", { field: "${path}", v: 90 })) + .required(tValid("zoom.extent.mandatory", { field: "${path}" })) + .transform(transform) + .test({ + name: "ymin_check", + message: tValid("zoom.f1_less_than_f2", { field1: "ymin", field2: "ymax" }), + test: (value, context) => { + const ymax = context.parent.ymax; + return ymax !== undefined ? value < ymax : true; + }, + }), + xmax: yup + .number() + .typeError(tValid("zoom.extent.nan", { field: "${path}" })) + .min(-180, tValid("zoom.greater_than", { field: "${path}", v: -180 })) + .max(180, tValid("zoom.less_than", { field: "${path}", v: 180 })) + .required(tValid("zoom.extent.mandatory", { field: "${path}" })) + .transform(transform) + .test({ + name: "xmax_check", + message: tValid("zoom.f1_less_than_f2", { field1: "xmin", field2: "xmax" }), + test: (value, context) => { + const xmin = context.parent.xmin; + return xmin !== undefined ? value > xmin : true; + }, + }), + ymax: yup + .number() + .typeError(tValid("zoom.extent.nan", { field: "${path}" })) + .min(-90, tValid("zoom.greater_than", { field: "${path}", v: -90 })) + .max(90, tValid("zoom.less_than", { field: "${path}", v: 90 })) + .required(tValid("zoom.extent.mandatory", { field: "${path}" })) + .transform(transform) + .test({ + name: "ymax_check", + message: tValid("zoom.f1_less_than_f2", { field1: "ymin", field2: "ymax" }), + test: (value, context) => { + const ymin = context.parent.ymin; + return ymin !== undefined ? value > ymin : true; + }, + }), + }); + + const form = useForm({ + mode: "onChange", + resolver: yupResolver(schema), + }); + const { + register, + getValues: getFormValues, + formState: { errors }, + handleSubmit, + resetField, + } = form; + + const clear = () => { + ["xmin", "ymin", "xmax", "ymax"].forEach((f) => resetField(f as FieldName, undefined)); + }; + + const onChoiceChanged = (v) => { + clear(); + setChoice(v); + }; + + const onSubmit = () => { + const values = getFormValues(); + onApply([values.xmin, values.ymin, values.xmax, values.ymax]); + setChoice("manual"); + clear(); + }; + + return ( + <> + {createPortal( + { + setChoice("manual"); + clear(); + onCancel(); + }, + priority: "secondary", + }, + { + children: tCommon("apply"), + doClosesModal: false, + onClick: handleSubmit(onSubmit), + priority: "primary", + }, + ]} + > + <> + onChoiceChanged("autocomplete"), + }, + }, + { + label: t("zoom.choice.manual"), + nativeInputProps: { + checked: choice === "manual", + onChange: () => onChoiceChanged("manual"), + }, + }, + ]} + /> + {/* TODO DECOMMENTER ET METTRE A LA PLACE DE Skeleton CI-DESSOUS + { + console.log(extent); + }} + /> */} + {choice === "autocomplete" ? ( + + ) : ( +
+ +
+
+ + +
+
+ + +
+
+
+ )} + +
, + document.body + )} + + ); +}; + +export { ExtentDialog, ExtentDialogModal }; diff --git a/assets/espaceco/pages/communities/management/ZoomAndCentering/RMap.tsx b/assets/espaceco/pages/communities/management/ZoomAndCentering/RMap.tsx new file mode 100644 index 00000000..220edf5e --- /dev/null +++ b/assets/espaceco/pages/communities/management/ZoomAndCentering/RMap.tsx @@ -0,0 +1,119 @@ +import { Feature } from "ol"; +import { defaults as defaultControls, ScaleLine } from "ol/control"; +import { Coordinate } from "ol/coordinate"; +import Point from "ol/geom/Point"; +import { DragPan, MouseWheelZoom } from "ol/interaction"; +import TileLayer from "ol/layer/Tile"; +import VectorLayer from "ol/layer/Vector"; +import Map from "ol/Map"; +import { fromLonLat } from "ol/proj"; +import VectorSource from "ol/source/Vector"; +import WMTS, { optionsFromCapabilities } from "ol/source/WMTS"; +import Icon from "ol/style/Icon"; +import Style from "ol/style/Style"; +import View from "ol/View"; +import { CSSProperties, FC, useEffect, useMemo, useRef } from "react"; +import olDefaults from "../../../../../data/ol-defaults.json"; +import useCapabilities from "../../../../../hooks/useCapabilities"; +import punaise from "../../../../../img/punaise.png"; +import DisplayCenterControl from "../../../../../ol/controls/DisplayCenterControl"; + +const mapStyle: CSSProperties = { + height: "400px", +}; + +type RMapProps = { + position: Coordinate | null; + // NOTE Supprimé car si la position n'est pas dans l'extent, le centre de la carte (position) est déplacé + // extent?: Extent; + zoom: number; + zoomMin: number; + zoomMax: number; + onMove: (center: Coordinate, zoom?: number) => void; +}; + +const RMap: FC = ({ position, zoom, zoomMin, zoomMax, onMove }) => { + const mapTargetRef = useRef(null); + const mapRef = useRef(); + + // Création de la couche openlayers de fond (bg layer) + const { data: capabilities } = useCapabilities(); + + const bgLayer = useMemo(() => { + if (!capabilities) return; + + const wmtsOptions = optionsFromCapabilities(capabilities, { + layer: olDefaults.default_background_layer, + }); + + if (!wmtsOptions) return; + + const bgLayer = new TileLayer({ + source: new WMTS(wmtsOptions), + }); + + return bgLayer; + }, [capabilities]); + + const center = useMemo(() => { + return position ? fromLonLat(position) : fromLonLat(olDefaults.center); + }, [position]); + + // Création de la carte une fois bg layer créée + useEffect(() => { + if (!bgLayer) return; + + const feature = new Feature(new Point(center)); + + // layer punaise + const source = new VectorSource(); + source.addFeatures([feature]); + const layer = new VectorLayer({ + source: source, + style: new Style({ + image: new Icon({ + src: punaise, + // ancrage de la punaise (non centrée) + anchor: [0.5, 0], + anchorOrigin: "bottom-left", + }), + }), + }); + + mapRef.current = new Map({ + target: mapTargetRef.current as HTMLElement, + layers: [bgLayer, layer], + controls: defaultControls().extend([new ScaleLine(), new DisplayCenterControl({})]), + interactions: [ + new DragPan(), + new MouseWheelZoom({ + useAnchor: false, + }), + ], + view: new View({ + center: center, + zoom: zoom, + minZoom: zoomMin, + maxZoom: zoomMax, + }), + }); + + mapRef.current.on("moveend", (e) => { + const map = e.map; + const centerView = map.getView().getCenter() as Coordinate; + const z = map.getView().getZoom() as number; + + // Rien n'a bougé + if (Math.round(z) === zoom && Math.abs(centerView[0] - center[0]) < 1 && Math.abs(centerView[1] - center[1]) < 1) { + return; + } + onMove(centerView, Math.round(z) !== zoom ? z : undefined); + }); + + return () => mapRef.current?.setTarget(undefined); + }, [bgLayer, center, zoom, zoomMin, zoomMax, onMove]); + + return
; +}; + +export default RMap; diff --git a/assets/espaceco/pages/communities/management/ZoomAndCentering/Search.tsx b/assets/espaceco/pages/communities/management/ZoomAndCentering/Search.tsx new file mode 100644 index 00000000..3290c20b --- /dev/null +++ b/assets/espaceco/pages/communities/management/ZoomAndCentering/Search.tsx @@ -0,0 +1,64 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import MuiDsfrThemeProvider from "@codegouvfr/react-dsfr/mui"; +import Autocomplete from "@mui/material/Autocomplete"; +import TextField from "@mui/material/TextField"; +import { useQuery } from "@tanstack/react-query"; +import { Coordinate } from "ol/coordinate"; +import { fromLonLat } from "ol/proj"; +import { FC, ReactNode } from "react"; +import { useDebounceValue } from "usehooks-ts"; +import { SearchResult } from "../../../../../@types/app_espaceco"; +import { useTranslation } from "../../../../../i18n/i18n"; +import RQKeys from "../../../../../modules/espaceco/RQKeys"; +import { jsonFetch } from "../../../../../modules/jsonFetch"; + +type SearchProps = { + label: ReactNode; + hintText?: ReactNode; + filter: Record; + onChange: (value: Coordinate | null) => void; +}; + +const autocompleteUrl = "https://data.geopf.fr/geocodage/completion"; + +const Search: FC = ({ label, hintText, filter, onChange }) => { + const { t } = useTranslation("Search"); + + const [text, setText] = useDebounceValue("", 500); + + const searchQuery = useQuery({ + queryKey: RQKeys.searchAddress(text), + queryFn: async ({ signal }) => { + const qParams = new URLSearchParams({ text: text, ...filter }).toString(); + return jsonFetch(`${autocompleteUrl}?${qParams}`, { signal }); + }, + enabled: text.length >= 3, + }); + + return ( +
+ + + option.fulltext} + options={searchQuery.data?.results ?? []} + filterOptions={(x) => x} + renderInput={(params) => } + isOptionEqualToValue={(option, v) => option.fulltext === v.fulltext} + onInputChange={(_, v) => setText(v)} + onChange={(_, v) => { + if (v) onChange(fromLonLat([v.x, v.y])); + }} + /> + +
+ ); +}; + +export default Search; diff --git a/assets/espaceco/pages/communities/management/ZoomAndCentering/SearchGrids.tsx b/assets/espaceco/pages/communities/management/ZoomAndCentering/SearchGrids.tsx new file mode 100644 index 00000000..aecdb831 --- /dev/null +++ b/assets/espaceco/pages/communities/management/ZoomAndCentering/SearchGrids.tsx @@ -0,0 +1,62 @@ +import { fr } from "@codegouvfr/react-dsfr"; +import MuiDsfrThemeProvider from "@codegouvfr/react-dsfr/mui"; +import Autocomplete from "@mui/material/Autocomplete"; +import { useQuery } from "@tanstack/react-query"; +import { FC, ReactNode } from "react"; +import { useDebounceValue } from "usehooks-ts"; +import { GetResponse, SearchGridFilters } from "../../../../../@types/app_espaceco"; +import { Grids } from "../../../../../@types/espaceco"; +import { useTranslation } from "../../../../../i18n/i18n"; +import RQKeys from "../../../../../modules/espaceco/RQKeys"; +import api from "../../../../api"; +import TextField from "@mui/material/TextField"; +import { Extent } from "ol/extent"; + +export type SearchGridsProps = { + label: ReactNode; + hintText?: ReactNode; + filters: SearchGridFilters; + onChange: (value: Extent | null) => void; +}; + +const SearchGrids: FC = ({ label, hintText, filters, onChange }) => { + const { t } = useTranslation("Search"); + + const [text, setText] = useDebounceValue("", 500); + + const searchQuery = useQuery>({ + queryKey: RQKeys.searchGrids(text), + queryFn: async ({ signal }) => { + return api.grid.search(text, filters, { signal }); + }, + staleTime: 1000 * 60, + enabled: text.length >= 2, + }); + + return ( +
+ + + `${option.name} : ${option.title}`} + options={searchQuery.data?.content ?? []} + filterOptions={(x) => x} + renderInput={(params) => } + isOptionEqualToValue={(option, v) => option.name === v.name} + onInputChange={(_, v) => setText(v)} + onChange={(_, v) => { + if (v) onChange(v.extent); + }} + /> + +
+ ); +}; + +export default SearchGrids; diff --git a/assets/espaceco/pages/communities/management/validationTr.tsx b/assets/espaceco/pages/communities/management/validationTr.tsx index f97b36f3..a6d8a2f3 100644 --- a/assets/espaceco/pages/communities/management/validationTr.tsx +++ b/assets/espaceco/pages/communities/management/validationTr.tsx @@ -12,6 +12,11 @@ export const { i18n } = declareComponentKeys< | "description.logo.size_error" | "description.logo.dimensions_error" | "description.logo.format_error" + | { K: "zoom.extent.nan"; P: { field: string }; R: string } + | { K: "zoom.extent.mandatory"; P: { field: string }; R: string } + | { K: "zoom.f1_less_than_f2"; P: { field1: string; field2: string }; R: string } + | { K: "zoom.less_than"; P: { field: string; v: number }; R: string } + | { K: "zoom.greater_than"; P: { field: string; v: number }; R: string } >()("ManageCommunityValidations"); export const ManageCommunityValidationsFrTranslations: Translations<"fr">["ManageCommunityValidations"] = { @@ -24,6 +29,11 @@ export const ManageCommunityValidationsFrTranslations: Translations<"fr">["Manag "description.logo.size_error": "La taille du fichier ne peut excéder 5 Mo", "description.logo.dimensions_error": "Les dimensions maximales de l'image sont de 400px x 400px", "description.logo.format_error": "Le fichier doit être au format jpeg ou png", + "zoom.extent.nan": ({ field }) => `${field} n'est pas un nombre`, + "zoom.extent.mandatory": ({ field }) => `La valeur ${field} est obligatoire`, + "zoom.f1_less_than_f2": ({ field1, field2 }) => `La valeur de ${field1} doit être inférieure à la valeur de ${field2}`, + "zoom.less_than": ({ field, v }) => `La valeur de ${field} doit être inférieure ou égale à ${v}`, + "zoom.greater_than": ({ field, v }) => `La valeur de ${field} doit être supérieure ou égale à ${v}`, }; export const ManageCommunityValidationsEnTranslations: Translations<"en">["ManageCommunityValidations"] = { @@ -36,4 +46,9 @@ export const ManageCommunityValidationsEnTranslations: Translations<"en">["Manag "description.logo.size_error": undefined, "description.logo.dimensions_error": undefined, "description.logo.format_error": undefined, + "zoom.extent.nan": ({ field }) => `${field} is not a number`, + "zoom.extent.mandatory": ({ field }) => `${field} value is mandatory`, + "zoom.f1_less_than_f2": ({ field1, field2 }) => `${field1} value must be less then ${field2} value`, + "zoom.less_than": ({ field, v }) => `${field} value must be less or equal to ${v}`, + "zoom.greater_than": ({ field, v }) => `${field} value must be greater or equal to ${v}`, }; diff --git a/assets/i18n/Common.tsx b/assets/i18n/Common.tsx index 7e83649f..58b47284 100644 --- a/assets/i18n/Common.tsx +++ b/assets/i18n/Common.tsx @@ -6,6 +6,7 @@ export const { i18n } = declareComponentKeys< | "add" | "adding" | "modify" + | "apply" | "modifying" | "removing" | "loading" @@ -39,6 +40,7 @@ export const commonFrTranslations: Translations<"fr">["Common"] = { add: "Ajouter", adding: "Ajout en cours ...", modify: "Modifier", + apply: "Appliquer", modifying: "Modification en cours ...", removing: "Suppression en cours ...", loading: "Chargement ...", @@ -72,6 +74,7 @@ export const commonEnTranslations: Translations<"en">["Common"] = { add: "Add", adding: "Adding ...", modify: "Modify", + apply: "Apply", modifying: "modifying ...", removing: "Removing ...", loading: "Loading ...", diff --git a/assets/i18n/i18n.ts b/assets/i18n/i18n.ts index 2c700584..0fbfd752 100644 --- a/assets/i18n/i18n.ts +++ b/assets/i18n/i18n.ts @@ -53,7 +53,7 @@ export type ComponentKey = | typeof import("../espaceco/pages/communities/CommunityListTr").i18n | typeof import("../espaceco/pages/communities/ManageCommunityTr").i18n | typeof import("../espaceco/pages/communities/management/validationTr").i18n - | typeof import("../espaceco/pages/communities/management/Search").i18n; + | typeof import("../espaceco/pages/communities/management/SearchTr").i18n; export type Translations = GenericTranslations; export type LocalizedString = Parameters[0]; diff --git a/assets/i18n/languages/en.tsx b/assets/i18n/languages/en.tsx index 0e7d0138..e553be2e 100644 --- a/assets/i18n/languages/en.tsx +++ b/assets/i18n/languages/en.tsx @@ -27,7 +27,7 @@ import { PermissionsEnTranslations } from "../../entrepot/pages/users/permission import { CommunityListEnTranslations } from "../../espaceco/pages/communities/CommunityListTr"; import { ManageCommunityEnTranslations } from "../../espaceco/pages/communities/ManageCommunityTr"; import { ManageCommunityValidationsEnTranslations } from "../../espaceco/pages/communities/management/validationTr"; -import { SearchEnTranslations } from "../../espaceco/pages/communities/management/Search"; +import { SearchEnTranslations } from "../../espaceco/pages/communities/management/SearchTr"; import { TMSStyleFilesManagerEnTranslations } from "../../modules/Style/TMSStyleFilesManager"; import { contactEnTranslations } from "../../pages/assistance/contact/Contact"; import { mapboxStyleValidationEnTranslations } from "../../validations/MapboxStyleValidator"; diff --git a/assets/i18n/languages/fr.tsx b/assets/i18n/languages/fr.tsx index c7da52d4..0cb5afdf 100644 --- a/assets/i18n/languages/fr.tsx +++ b/assets/i18n/languages/fr.tsx @@ -27,7 +27,7 @@ import { PermissionsFrTranslations } from "../../entrepot/pages/users/permission import { CommunityListFrTranslations } from "../../espaceco/pages/communities/CommunityListTr"; import { ManageCommunityFrTranslations } from "../../espaceco/pages/communities/ManageCommunityTr"; import { ManageCommunityValidationsFrTranslations } from "../../espaceco/pages/communities/management/validationTr"; -import { SearchEnTranslations, SearchFrTranslations } from "../../espaceco/pages/communities/management/Search"; +import { SearchFrTranslations } from "../../espaceco/pages/communities/management/SearchTr"; import { TMSStyleFilesManagerFrTranslations } from "../../modules/Style/TMSStyleFilesManager"; import { contactFrTranslations } from "../../pages/assistance/contact/Contact"; import { mapboxStyleValidationFrTranslations } from "../../validations/MapboxStyleValidator"; diff --git a/assets/img/punaise.png b/assets/img/punaise.png new file mode 100644 index 0000000000000000000000000000000000000000..0e0786a48c8e069ea7354e8776b56f80f0d1b057 GIT binary patch literal 1454 zcmV;f1yTBmP)2&#bI$i)fdBjc4}N&Icjxk*bKd7Wm-l_o@xTQFfxuWMcT=WQCQ-&v zlFjGyl)osyQK~5A9*^fJ;sq-AQ07r81MX32`g)x7cpOM%GTBJEC9D#t<4+&`894nn z&~!%EUy%Y_JsKFB1&rimc_N1Xp0boPwAVEel6Zp2%aneq;@{)I&Mm<1&w&3<+5g_) zVL)n6-S1Qlu)59|ATp`%e$|+%??ek8n2AuD5Dfz{4aHh(_c|CaAt(S(Z zeq?f^vJ0g&n(ddI2b5zu^-f^;b-H}=2(a&KeaxNUO$45=(sl>f!{xb2tmZ^xmVu8~ zXanj}k;t9NTVjn(H4cA-D; z`1`aC1)WK#KTXizq>Z$-gXj{S8o6HMz<^`F0*$BbJI^l;KYr06ZKSOoM3?B)nC>-R zlz#IU`;JM2fjj)MZ%Sz^ee4j@sg@=!xSQ>eo}*2+8}lxGoKBENnh@~?&$u1*4tEEq zJ|UexZ?MvrY~QKnyn7-}eZq7Kk#j1SJjA|J-2wMRoce@xo)aR6RW@~;eWxQoX`>Y@ zq^FSQt8rPVYMg0*O-EN~c$35s_VUP|T3>pd0<@ zRV>}AJvZl(GJ!;Dy?WnxYMHE4jikY?3ww`*jIBus)*0Oqp`wJ@a_<VGvlsP2Q?s(-SnGsC( zQ&MpD1h9HG@A_xEM?(Bb=?>t^;Zka#Od*L*fvvY|>+7jkc13ViDv)2+{n^N0YmpH6 zyCM;lh#?~Z>}FNSoD1aSxhIkHknXFonPYy@-Dim;;}p};N=y9WUBHRj*hr*~2cG$g z)>p+{Vk~bUk>;p34BN*OeY_S?TG4-?KGIInxfSipQPm_`%~5T(z3Mo=J*y;ra>Jh=dPKyKk*zQB z|1kApBuBcjA+ph+p)`j(^otQ41SNwcPR266H&!BMz{23u)Z$koBRa`1e$65wD2$ba zD ["searchAddress", search], + searchGrids: (text: string): string[] => ["searchGrids", text], }; export default RQKeys; diff --git a/assets/ol/controls/DisplayCenterControl.ts b/assets/ol/controls/DisplayCenterControl.ts new file mode 100644 index 00000000..44407968 --- /dev/null +++ b/assets/ol/controls/DisplayCenterControl.ts @@ -0,0 +1,47 @@ +/* Copyright (c) 2024 P.Prevautel + released under the CeCILL-B license (French BSD license) + (http://www.cecill.info/licences/Licence_CeCILL-B_V1-en.txt). +*/ +import * as olColor from "ol/color"; +import Control from "ol/control/Control"; + +import "../../sass/pages/espaceco/drawcenter.scss"; + +type DisplayCenterOptions = { + color?: number[] | string; + width?: number; +}; + +const defaultColor = "#000"; + +/** DisplayCenter draw a target at the center of the map. + * @param + * - color {ol.Color or string} line color + * - width {integer} line width + */ +class DisplayCenterControl extends Control { + constructor(options: DisplayCenterOptions) { + const { color = defaultColor, width = 1 } = options; + + let c: string = defaultColor; + try { + if (Array.isArray(color)) { + c = olColor.asString(color); + } else if (typeof color === "string") { + olColor.fromString(color) as olColor.Color; + c = color; + } + } catch (e) { + c = "#000"; + } + + const div = document.createElement("div"); + div.className = "ol-target ol-unselectable ol-control"; + div.style.setProperty("--drawcenter-background", c); + div.style.setProperty("--drawcenter-width", `${width}px`); + + super({ element: div }); + } +} + +export default DisplayCenterControl; diff --git a/assets/sass/components/zoom-range.scss b/assets/sass/components/zoom-range.scss index 13dbdb14..789f53c4 100644 --- a/assets/sass/components/zoom-range.scss +++ b/assets/sass/components/zoom-range.scss @@ -1,17 +1,17 @@ -.zoom-range-map { - height: 300px; -} - -.ui-map-zoom-levels { +.frx-zoom-range { display: flex; - .ui-top-zoom-level, - .ui-bottom-zoom-level { + .frx-top-zoom, + .frx-bottom-zoom { height: 300px; border: 1px solid lightgray; flex: 0 1 50%; margin: 0 1em; } - .ui-bottom-zoom-level { + .frx-bottom-zoom { margin-left: 0; } + + .frx-zoom-range-sm { + height: 150px; + } } diff --git a/assets/sass/pages/espaceco/drawcenter.scss b/assets/sass/pages/espaceco/drawcenter.scss new file mode 100644 index 00000000..b1c1291b --- /dev/null +++ b/assets/sass/pages/espaceco/drawcenter.scss @@ -0,0 +1,26 @@ +.ol-target { + inset: 0; + pointer-events: none !important; + background-color: transparent !important ; +} + +.ol-target:before, +.ol-target:after { + content: ""; + position: absolute; + background: var(--drawcenter-background); +} + +.ol-target:before { + width: 100%; + left: 0; + top: 50%; + height: var(--drawcenter-width, 1px); +} + +.ol-target:after { + height: 100%; + left: 50%; + top: 0; + width: var(--drawcenter-width, 1px); +} diff --git a/src/Controller/EspaceCo/GridController.php b/src/Controller/EspaceCo/GridController.php new file mode 100644 index 00000000..7f288ef8 --- /dev/null +++ b/src/Controller/EspaceCo/GridController.php @@ -0,0 +1,46 @@ + true], + condition: 'request.isXmlHttpRequest()' +)] +class GridController extends AbstractController implements ApiControllerInterface +{ + public const SEARCH_LIMIT = 20; + + public function __construct( + private GridApiService $gridApiService + ) { + } + + #[Route('/search', name: 'search', methods: ['GET'])] + public function get( + #[MapQueryParameter] string $text, + #[MapQueryParameter] ?string $searchBy, + #[MapQueryParameter] ?string $fields, + #[MapQueryParameter] ?string $adm, + #[MapQueryParameter] ?int $page = 1, + #[MapQueryParameter] ?int $limit = self::SEARCH_LIMIT, + ): JsonResponse { + try { + $response = $this->gridApiService->getGrids($text, $searchBy, $fields, $adm, $page, $limit); + + return new JsonResponse($response); + } catch (ApiException $ex) { + throw new CartesApiException($ex->getMessage(), $ex->getStatusCode(), $ex->getDetails(), $ex); + } + } +} diff --git a/src/Services/EspaceCoApi/GridApiService.php b/src/Services/EspaceCoApi/GridApiService.php new file mode 100644 index 00000000..66dbaa77 --- /dev/null +++ b/src/Services/EspaceCoApi/GridApiService.php @@ -0,0 +1,35 @@ + $text, 'page' => $page, 'limit' => $limit]; + if ($searchBy) { + $query['searchBy'] = $searchBy; + } + if ($fields) { + $query['fields'] = $fields; + } + if ($adm) { + $query['adm'] = $adm; + } + + $response = $this->request('GET', 'grids', [], $query, [], false, true, true); + + $contentRange = $response['headers']['content-range'][0]; + $totalPages = $this->getResultsPageCount($contentRange, $limit); + + $previousPage = 1 === $page ? null : $page - 1; + $nextPage = $page + 1 > $totalPages ? null : $page + 1; + + return [ + 'content' => $response['content'], + 'totalPages' => $totalPages, + 'previousPage' => $previousPage, + 'nextPage' => $nextPage, + ]; + } +}