diff --git a/.eslintrc b/.eslintrc index 27a0c22c4..783ae46d9 100644 --- a/.eslintrc +++ b/.eslintrc @@ -45,10 +45,7 @@ "no-console": "off", "no-proto": "off", "no-prototype-builtins": "off", - "linebreak-style": [ - "error", - "unix" - ], + "linebreak-style": "off", "padded-blocks": [ "error", { diff --git a/build/webpack/controls.webpack.config.js b/build/webpack/controls.webpack.config.js index 99544a143..38247e5e7 100644 --- a/build/webpack/controls.webpack.config.js +++ b/build/webpack/controls.webpack.config.js @@ -74,6 +74,14 @@ module.exports = (env, argv) => { // formats break; case "SearchEngine": + case "SearchEngineBase": + case "SearchEngineGeocodeIGN": + case "SearchEngineAdvanced": + case "LocationAdvancedSearch": + case "ParcelAdvancedSearch": + case "AbstractAdvancedSearch": + case "InseeAdvancedSearch": + case "CoordinateAdancedSearch": // crs break; case "MeasureArea": diff --git a/build/webpack/extend.themes.webpack.js b/build/webpack/extend.themes.webpack.js index fb030783f..4ccc5f627 100644 --- a/build/webpack/extend.themes.webpack.js +++ b/build/webpack/extend.themes.webpack.js @@ -28,6 +28,7 @@ module.exports = (env, argv) => { path.join(rootdir, "src", "packages", "CSS", "Controls/ReverseGeocoding", "GPFreverseGeocodingStyle.css"), path.join(rootdir, "src", "packages", "CSS", "Controls/Route", "GPFrouteStyle.css"), path.join(rootdir, "src", "packages", "CSS", "Controls/SearchEngine", "GPFsearchEngineStyle.css"), + path.join(rootdir, "src", "packages", "CSS", "Controls/SearchEngine", "GPFadvancedSearchEngineStyle.css"), path.join(rootdir, "src", "packages", "CSS", "Controls/ToolBoxMeasure", "GPFtoolBoxMeasureStyle.css"), path.join(rootdir, "src", "packages", "CSS", "Controls/Zoom", "GPFzoomStyle.css"), path.join(rootdir, "src", "packages", "CSS", "Controls/FullScreen", "GPFfullScreenStyle.css"), @@ -64,6 +65,7 @@ module.exports = (env, argv) => { path.join(rootdir, "src", "packages", "CSS", "Controls/ReverseGeocoding", "DSFRreverseGeocodingStyle.css"), path.join(rootdir, "src", "packages", "CSS", "Controls/Route", "DSFRrouteStyle.css"), path.join(rootdir, "src", "packages", "CSS", "Controls/SearchEngine", "DSFRsearchEngineStyle.css"), + path.join(rootdir, "src", "packages", "CSS", "Controls/SearchEngine", "DSFRadvancedSearchEngineStyle.css"), path.join(rootdir, "src", "packages", "CSS", "Controls/ToolBoxMeasure", "DSFRtoolBoxMeasureStyle.css"), path.join(rootdir, "src", "packages", "CSS", "Controls/Zoom", "DSFRzoomStyle.css"), path.join(rootdir, "src", "packages", "CSS", "Controls/FullScreen", "DSFRfullScreenStyle.css"), diff --git a/build/webpack/modules.webpack.config.js b/build/webpack/modules.webpack.config.js index 0cc800678..b5043c583 100644 --- a/build/webpack/modules.webpack.config.js +++ b/build/webpack/modules.webpack.config.js @@ -45,6 +45,14 @@ module.exports = (env, argv) => { "GpfExtOlReverseGeocode" : path.join(rootdir, "src", "packages", "Controls/ReverseGeocode", "ReverseGeocode.js"), "GpfExtOlRoute" : path.join(rootdir, "src", "packages", "Controls/Route", "Route.js"), "GpfExtOlSearchEngine" : path.join(rootdir, "src", "packages", "Controls/SearchEngine", "SearchEngine.js"), + "GpfExtOlSearchEngineBase" : path.join(rootdir, "src", "packages", "Controls/SearchEngine", "SearchEngineBase.js"), + "GpfExtOlSearchEngineGeocodeIGN" : path.join(rootdir, "src", "packages", "Controls/SearchEngine", "SearchEngineGeocodeIGN.js"), + "GpfExtOlSearchEngineAdvanced" : path.join(rootdir, "src", "packages", "Controls/SearchEngine", "SearchEngineAdvanced.js"), + "GpfExtOlLocationAdvancedSearch" : path.join(rootdir, "src", "packages", "Controls/SearchEngine", "LocationAdvancedSearch.js"), + "GpfExtOlParcelAdvancedSearch" : path.join(rootdir, "src", "packages", "Controls/SearchEngine", "ParcelAdvancedSearch.js"), + "GpfExtOlInseeAdvancedSearch" : path.join(rootdir, "src", "packages", "Controls/SearchEngine", "InseeAdvancedSearch.js"), + "GpfExtOlAbstractAdvancedSearch" : path.join(rootdir, "src", "packages", "Controls/SearchEngine", "AbstractAdvancedSearch.js"), + "GpfExtOlCoordinateAdvancedSearch" : path.join(rootdir, "src", "packages", "Controls/SearchEngine", "CoordinateAdvancedSearch.js"), "GpfExtOlExport" : path.join(rootdir, "src", "packages", "Controls/Export", "Export.js"), "GpfExtOlMeasureArea" : path.join(rootdir, "src", "packages", "Controls", "Measures", "MeasureArea.js"), "GpfExtOlMeasureAzimuth" : path.join(rootdir, "src", "packages", "Controls", "Measures", "MeasureAzimuth.js"), @@ -58,6 +66,9 @@ module.exports = (env, argv) => { "GpfExtOlControlList" : path.join(rootdir, "src", "packages", "Controls/ControlList", "ControlList.js"), "GpfExtOlContextMenu" : path.join(rootdir, "src", "packages", "Controls/ContextMenu", "ContextMenu.js"), "GpfExtOlReporting" : path.join(rootdir, "src", "packages", "Controls/Reporting", "Reporting.js"), + // Interactions + "GpfExtOlDrawingInteraction" : path.join(rootdir, "src", "packages", "Interactions/Drawing.js"), + "GpfExtOlSelectingInteraction" : path.join(rootdir, "src", "packages", "Interactions/Selecting.js"), // Formats étendus "GpfExtOlFormats" : [ path.join(rootdir, "src", "packages", "Formats", "GeoJSON.js"), diff --git a/demos/angular-project/angular.json b/demos/angular-project/angular.json index fc1bbbf42..26faa96e5 100644 --- a/demos/angular-project/angular.json +++ b/demos/angular-project/angular.json @@ -38,6 +38,7 @@ "node_modules/geopf-extensions-openlayers/src/packages/CSS/Controls/MousePosition/GPFmousePosition.css", "node_modules/geopf-extensions-openlayers/src/packages/CSS/Controls/Export/GPFexport.css", "node_modules/geopf-extensions-openlayers/src/packages/CSS/Controls/SearchEngine/GPFsearchEngine.css", + "node_modules/geopf-extensions-openlayers/src/packages/CSS/Controls/SearchEngine/GPFadvancedSearchEngine.css", "node_modules/geopf-extensions-openlayers/src/packages/CSS/Controls/Editor/GPFeditor.css", "node_modules/geopf-extensions-openlayers/src/packages/CSS/Controls/ReverseGeocoding/GPFreverseGeocoding.css", "node_modules/geopf-extensions-openlayers/src/packages/CSS/Controls/Drawing/GPFdrawing.css", diff --git a/samples-src/pages/tests/DrawingInteraction/pages-ol-drawinginteraction-modules-default.html b/samples-src/pages/tests/DrawingInteraction/pages-ol-drawinginteraction-modules-default.html new file mode 100644 index 000000000..66c8264e1 --- /dev/null +++ b/samples-src/pages/tests/DrawingInteraction/pages-ol-drawinginteraction-modules-default.html @@ -0,0 +1,100 @@ +{{#extend "ol-sample-modules-dsfr-layout"}} + +{{#content "vendor"}} + + + +{{/content}} + +{{#content "head"}} + Sample openlayers Drawing interaction +{{/content}} + +{{#content "style"}} + +{{/content}} + +{{#content "body"}} +

Outil de dessin avec style

+ +
+
+

Dernière sélection :

+ + + +{{/content}} + +{{#content "js"}} + +{{/content}} + +{{/extend}} diff --git a/samples-src/pages/tests/SearchEngine/pages-ol-searchenginebase-modules-default.html b/samples-src/pages/tests/SearchEngine/pages-ol-searchenginebase-modules-default.html new file mode 100644 index 000000000..9fe1e54a5 --- /dev/null +++ b/samples-src/pages/tests/SearchEngine/pages-ol-searchenginebase-modules-default.html @@ -0,0 +1,88 @@ +{{#extend "ol-sample-modules-layout"}} + +{{#content "vendor"}} + + + +{{/content}} + +{{#content "head"}} + Sample openlayers SearchEngine +{{/content}} + +{{#content "style"}} + +{{/content}} + +{{#content "body"}} +

Ajout du moteur de recherche avec les options par défaut

+ +
+
+

Dernière sélection :

+{{/content}} + +{{#content "js"}} + +{{/content}} + +{{/extend}} diff --git a/samples-src/pages/tests/SearchEngine/pages-ol-searchenginebase-modules-dsfr-geocodeAdvanced.html b/samples-src/pages/tests/SearchEngine/pages-ol-searchenginebase-modules-dsfr-geocodeAdvanced.html new file mode 100644 index 000000000..5341c5045 --- /dev/null +++ b/samples-src/pages/tests/SearchEngine/pages-ol-searchenginebase-modules-dsfr-geocodeAdvanced.html @@ -0,0 +1,121 @@ +{{#extend "ol-sample-modules-dsfr-layout"}} + +{{#content "vendor"}} + + + + + + + + +{{/content}} + +{{#content "head"}} + Sample openlayers SearchEngine +{{/content}} + +{{#content "style"}} + +{{/content}} + +{{#content "body"}} +

Ajout du moteur de recherche avec les options par défaut

+ +
+
+

Dernière sélection :

+ +{{/content}} + +{{#content "js"}} + +{{/content}} + +{{/extend}} diff --git a/samples-src/pages/tests/SearchEngine/pages-ol-searchenginebase-modules-dsfr-geocodeIGN.html b/samples-src/pages/tests/SearchEngine/pages-ol-searchenginebase-modules-dsfr-geocodeIGN.html new file mode 100644 index 000000000..53f08fe21 --- /dev/null +++ b/samples-src/pages/tests/SearchEngine/pages-ol-searchenginebase-modules-dsfr-geocodeIGN.html @@ -0,0 +1,79 @@ +{{#extend "ol-sample-modules-dsfr-layout"}} + +{{#content "vendor"}} + + + +{{/content}} + +{{#content "head"}} + Sample openlayers SearchEngine +{{/content}} + +{{#content "style"}} + +{{/content}} + +{{#content "body"}} +

Ajout du moteur de recherche avec les options par défaut

+ +
+
+

Dernière sélection :

+{{/content}} + +{{#content "js"}} + +{{/content}} + +{{/extend}} diff --git a/samples-src/pages/tests/SearchEngine/pages-ol-searchenginebase-modules-geocodeIGN.html b/samples-src/pages/tests/SearchEngine/pages-ol-searchenginebase-modules-geocodeIGN.html new file mode 100644 index 000000000..a86fc6c0e --- /dev/null +++ b/samples-src/pages/tests/SearchEngine/pages-ol-searchenginebase-modules-geocodeIGN.html @@ -0,0 +1,86 @@ +{{#extend "ol-sample-modules-layout"}} + +{{#content "vendor"}} + + + +{{/content}} + +{{#content "head"}} + Sample openlayers SearchEngine +{{/content}} + +{{#content "style"}} + +{{/content}} + +{{#content "body"}} +

Ajout du moteur de recherche avec les options par défaut

+ +
+
+

Dernière sélection :

+{{/content}} + +{{#content "js"}} + +{{/content}} + +{{/extend}} diff --git a/src/index.js b/src/index.js index 0c45507be..02d4da920 100644 --- a/src/index.js +++ b/src/index.js @@ -24,6 +24,14 @@ export { default as LayerMapBox } from "./packages/Layers/LayerMapBox"; export { default as LayerSwitcher } from "./packages/Controls/LayerSwitcher/LayerSwitcher"; export { default as GetFeatureInfo } from "./packages/Controls/GetFeatureInfo/GetFeatureInfo"; export { default as SearchEngine } from "./packages/Controls/SearchEngine/SearchEngine"; +export { default as SearchEngineBase } from "./packages/Controls/SearchEngine/SearchEngineBase"; +export { default as SearchEngineAdvanced } from "./packages/Controls/SearchEngine/SearchEngineAdvanced"; +export { default as SearchEngineGeocodeIGN } from "./packages/Controls/SearchEngine/SearchEngineGeocodeIGN"; +export { default as AbstractAdvancedSearch } from "./packages/Controls/SearchEngine/AbstractAdvancedSearch"; +export { default as CoordinateAdvancedSearch } from "./packages/Controls/SearchEngine/CoordinateAdvancedSearch"; +export { default as InseeAdvancedSearch } from "./packages/Controls/SearchEngine/InseeAdvancedSearch"; +export { default as LocationAdvancedSearch } from "./packages/Controls/SearchEngine/LocationAdvancedSearch"; +export { default as ParcelAdvancedSearch } from "./packages/Controls/SearchEngine/ParcelAdvancedSearch"; export { default as MousePosition } from "./packages/Controls/MousePosition/MousePosition"; export { default as Drawing } from "./packages/Controls/Drawing/Drawing"; export { default as Route } from "./packages/Controls/Route/Route"; @@ -48,6 +56,17 @@ export { default as ControlList } from "./packages/Controls/ControlList/ControlL export { default as ContextMenu } from "./packages/Controls/ContextMenu/ContextMenu"; export { default as Reporting } from "./packages/Controls/Reporting/Reporting"; + +// Interactions +export { default as DrawingInteraction } from "./packages/Interactions/Drawing"; +export { default as SelectingInteraction } from "./packages/Interactions/Selecting"; + +// Services +export { default as AbstractSearchService } from "./packages/Services/AbstractSearchService"; +export { default as DefaultSearchService } from "./packages/Services/DefaultSearchService"; +export { default as IGNSearchService } from "./packages/Services/IGNSearchService"; +export { default as InseeSearchService } from "./packages/Services/InseeSearchService"; + // proj4 export { default as Proj4 } from "proj4"; diff --git a/src/packages/CSS/Controls/SearchEngine/DSFRadvancedSearchEngineStyle.css b/src/packages/CSS/Controls/SearchEngine/DSFRadvancedSearchEngineStyle.css new file mode 100644 index 000000000..ae7fb8821 --- /dev/null +++ b/src/packages/CSS/Controls/SearchEngine/DSFRadvancedSearchEngineStyle.css @@ -0,0 +1,133 @@ +/** STYLE DSFR RECHERCHE AVANCEE **/ + +div[data-open="true"] > section.fr-accordion::before { + box-shadow: inset 0 1px 0 0 var(--border-default-grey); +} + + +div[id^=GPsearchEngine-Advanced] { + width: 100%; +} + +@media (min-width: 36em) { + div[id^=GPsearchEngine-Advanced] { + width: 360px; + } +} + +div[id^=GPsearchEngine-AdvancedContainer] > button.GPSearchEngine-locate { + margin-bottom : 0.75rem; +} + +form[id^=GPAdvancedForm-CoordinateAdvancedSearch] > .GPLonLatInputs { + display: flex; + flex-direction: row; + gap: 1rem; +} + +form[id^=GPAdvancedForm-CoordinateAdvancedSearch][data-unit="DMS"] > .GPLonLatInputs { + flex-direction: column; +} + +form[id^=GPAdvancedForm-CoordinateAdvancedSearch] > .GPLonLatInputs .display-mask { + display: none; +} + +form[id^=GPAdvancedForm-CoordinateAdvancedSearch][data-unit="DMS"] > .GPLonLatInputs .display-mask { + display: unset; + position: absolute; + top: 0.5rem; + left: 1rem; + pointer-events: none; + font-family: inherit; +} + +form[id^=GPAdvancedForm-CoordinateAdvancedSearch][data-unit="DMS"] > .GPLonLatInputs input[id^=CoordinateAdvancedSearch-] { + color: transparent; + caret-color: var(--text-default-grey); + letter-spacing: 2px; +} + + +form[id^=GPAdvancedForm-CoordinateAdvancedSearch] > .GPLonLatInputs select { + display: none; +} + +form[id^=GPAdvancedForm-CoordinateAdvancedSearch][data-unit="DMS"] > .GPLonLatInputs select { + display: unset; +} + +form[id^=GPAdvancedForm-CoordinateAdvancedSearch] > .GPLonLatInputs .GPCoordinateInputs { + display: flex; + flex-direction: row; + gap: 8px; +} + + +form[id^=GPAdvancedForm-CoordinateAdvancedSearch] > .GPLonLatInputs .GPCoordinateInputs select { + width:auto; +} + +.GPCoordinateInputs > div { + position: relative; +} + + +[id^="GPsearchEngine-Advanced"] > .GPAdvancedContainer { + background-color: var(--background-default-grey); +} + +[id^="GPsearchEngine-Advanced"] .GPsearchInputSubmit:disabled { + background-color: var(--background-action-high-blue-france); +} + +/* Surcharge DSFR */ +div[id^=GPsearchEngine-AdvancedContainer-] .fr-accordion .fr-collapse { + margin: 0 -0.25rem; + padding-left: 1rem; + padding-right: 1rem; +} + +.fr-icon-map-pin-add-line::before, +.fr-icon-map-pin-add-line::after { + -webkit-mask-image: url("./img/dsfr/map-pin-add-line.svg"); + mask-image: url("./img/dsfr/map-pin-add-line.svg"); +} +.fr-icon-map-pin-add-fill::before, +.fr-icon-map-pin-add-fill::after { + -webkit-mask-image: url("./img/dsfr/map-pin-add-fill.svg"); + mask-image: url("./img/dsfr/map-pin-add-fill.svg"); +} + +.fr-icon-bike-line::before, +.fr-icon-bike-line::after { + -webkit-mask-image: url("./img/dsfr/bike-line.svg"); + mask-image: url("./img/dsfr/bike-line.svg"); +} + +.fr-icon-parking-box-line::before, +.fr-icon-parking-box-line::after { + -webkit-mask-image: url("./img/dsfr/parking-box-line.svg"); + mask-image: url("./img/dsfr/parking-box-line.svg"); +} +.fr-icon-plane-line::before, +.fr-icon-plane-line::after { + -webkit-mask-image: url("./img/dsfr/plane-line.svg"); + mask-image: url("./img/dsfr/plane-line.svg"); +} +.fr-icon-subway-line::before, +.fr-icon-subway-line::after { + -webkit-mask-image: url("./img/dsfr/subway-line.svg"); + mask-image: url("./img/dsfr/subway-line.svg"); +} + +[id^="GPsearchEngine"] form[id^="GPsearchInput-Base-"].GPSearchBar > .GPInputGroup:has(> .GPsearchInputText:focus:focus-visible) { + outline-offset: 2px; + outline-width: 2px; + outline-color: #0a76f6; + outline-style: solid; +} + +[id^="GPsearchEngine"] form[id^="GPsearchInput-Base-"].GPSearchBar > .GPInputGroup >.GPsearchInputText:focus:focus-visible { + outline-style: none; +} \ No newline at end of file diff --git a/src/packages/CSS/Controls/SearchEngine/DSFRsearchEngineStyle.css b/src/packages/CSS/Controls/SearchEngine/DSFRsearchEngineStyle.css index 51af56796..2cee62301 100644 --- a/src/packages/CSS/Controls/SearchEngine/DSFRsearchEngineStyle.css +++ b/src/packages/CSS/Controls/SearchEngine/DSFRsearchEngineStyle.css @@ -1,3 +1,12 @@ + +/* Mode Mobile */ +@media (min-width: 576px) { + [id^="GPsearchEngine-"] { + top: 8px; + left: 46px; + } +} + .gpf-widget-padding { padding-top: 5px; padding-bottom: 5px; @@ -126,16 +135,19 @@ div.GPbuttonsContainer > button { width: 40px; } -form[id^=GPsearchInput-] { - margin-left: 8px; - width: 300px; -} +/* form[id^=GPsearchInput-] { + width: 312px; +} */ button[id^="GPshowSearchEnginePicto-"][aria-pressed="false"] + form[id^=GPsearchInput-] { - max-width: 300px; + max-width: 0px; border: none; } +button[id^="GPshowSearchEnginePicto-"][aria-pressed="true"] + form[id^=GPsearchInput-] { + max-width: 312px; +} + button[id^="GPshowSearchEnginePicto-"] { border-radius: 0 0.25rem 0 0; } @@ -228,6 +240,10 @@ div[id^=GPgeocodeResults-] { border-bottom: 1px solid var(--grey-900-175); } +.GPSearchBar > div.GPInputGroup, .GPSearchBar > div.GPInputGroup > input { + box-shadow: inset 0 -2px 0 0 var(--border-action-high-blue-france); +} + /* Mode Mobile */ @media (max-width: 576px) { @@ -263,3 +279,149 @@ div[id^=GPgeocodeResults-] { max-height: 4rem; } } + +[id^="GPsearchEngine"] .GPautoCompleteContainer { + background-color: var(--background-default-grey); +} + +[id^="GPsearchEngine"] ul { + padding: 0 12px; + margin: 0.75rem 0 0; + width: 100%; +} + +/* [id^="GPsearchEngine"] form[id^="GPsearchInput-Base-"] .GPOptionsContainer button { + font-size: 0.875rem; + line-height: 1.5rem; + min-height: 2rem; + padding: 0.25rem 0.75rem; +} */ + +[id^="GPsearchEngine"] ul:empty { + padding: unset; + margin: unset; +} + +[id^="GPsearchEngine"] ul li { + padding: 0.75rem 0.5rem; + color: var(--text-action-high-grey); + box-shadow: 0 -1px 0 0 var(--border-default-grey); +} + +[id^="GPsearchEngine"] ul li.GPsearchHistoric { + padding-right: 1.75rem; +} + +[id^="GPsearchEngine"] ul li.GPsearchHistoric > span { + position: absolute; + right: 1rem; + color: var(--grey-625-425); +} + +[id^="GPsearchEngine"] ul li:first-child { + box-shadow: none; +} + + +[id^="GPsearchEngine"] ul li::before { + margin-right: 0.5rem; +} + +[id^="GPsearchEngine"] ul li.active, +[id^="GPsearchEngine"] ul li:hover { + background-color: var(--background-default-grey-hover); +} + +[id^="GPsearchEngine"] ul li:active { + background-color: var(--background-default-grey-active); +} + +.fr-icon-history-line::before, +.fr-icon-history-line::after { + -webkit-mask-image: url("./img/dsfr/history-line.svg"); + mask-image: url("./img/dsfr/history-line.svg"); +} + +form[id^=GPsearchInput] > input[id^=GPsearchInputText] { + height: 3rem; + max-height: 3rem; +} + +.GPSearchPopup { + display: flex; + position: relative; + align-items: center; + justify-content: space-between; + flex-wrap: nowrap; + gap: 12px; + width: 288px; + + bottom: 17px; + padding: 0.75rem; + border: 1px solid var(--border-default-grey); + box-shadow: var(--overlap-shadow); + background-color: var(--background-default-grey); + font-size: 0.75em; +} +/* Border layer (slightly larger triangle) */ +.GPSearchPopup::before { + content: ""; + position: absolute; + border-top: 16px solid var(--border-default-grey); /* Border color */ + border-right: 15px solid transparent; + border-left: 15px solid transparent; + bottom: -17px; + margin-left: -15px; + left: 50%; +} + +/* Inner layer (actual background triangle) */ +.GPSearchPopup::after { + content: ""; + position: absolute; + border-top: 15px solid var(--background-default-grey); /* Same as popup bg */ + border-right: 14px solid transparent; + border-left: 14px solid transparent; + bottom: -15px; + margin-left: -14px; + left: 50%; +} + +.GPPopupContent { + line-height: 1.25rem; +} + +.GPButtonGroups { + display: flex; + flex-direction: row-reverse; + flex: 0 0 auto; + align-items: center; +} + +form.GPSearchBar > .GPInputGroup,form.GPSearchBar > button[id^=GPshowSearchEnginePicto-].fr-btn, form.GPSearchBar > .GPInputGroup > input { + height: 48px; + max-height: unset; +} + +form.GPSearchBar > button[id^=GPshowSearchEnginePicto-].fr-btn { + width: 48px; + max-width: unset; +} + +button[id^="GPSearchEngine-erase-btn"] { + display: none; +} + +form.GPSearchBar > .GPInputGroup > input[data-erase] ~ .GPOptionsContainer > button[id^="GPSearchEngine-erase-btn"] { + display:block; +} + +form.GPSearchBar > .GPInputGroup > input[data-erase] ~ .GPOptionsContainer > button[id^="GPSearchEngine-advanced-btn"] { + display:none; +} + +.fr-icon-ign-mer::before, +.fr-icon-ign-mer::after { + -webkit-mask-image: url("./img/dsfr/ign-mer.svg"); + mask-image: url("./img/dsfr/ign-mer.svg"); +} \ No newline at end of file diff --git a/src/packages/CSS/Controls/SearchEngine/GPFadvancedSearchEngine.css b/src/packages/CSS/Controls/SearchEngine/GPFadvancedSearchEngine.css new file mode 100644 index 000000000..c8f99e98f --- /dev/null +++ b/src/packages/CSS/Controls/SearchEngine/GPFadvancedSearchEngine.css @@ -0,0 +1,107 @@ +/** STYLE COMMUN RECHERCHE AVANCEE **/ + +div[id^=GPsearchEngine-Advanced] { + padding: 5px; + margin: -5px; +} + +div[id^=GPsearchEngine-Advanced] div[id^=GPsearchEngine-] { + width: 100%; + margin: 0 5px; +} + +form[id^=GPAdvancedForm-], form[id^=GPAdvancedForm-] * { + height : 100%; + width: 100%; +} + +form[id^=GPAdvancedForm-] > .GPFormFooter { + margin-top: 0.75rem; + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 16px; +} + +[id^="GPsearchEngine-Advanced"] form[id^="GPsearchInput-Base-"]:first-child { + display: flex; + flex-direction: row; +} + +form[id^=GPAdvancedForm-] > .GPFormFooter > button { + flex: 1 1 0; + display: flex; + justify-content: center; + align-items: center; +} + +form[id^=GPAdvancedForm-] ul.search-result { + list-style-type: none; + padding: 0; + margin: -0.5em 0 0; +} +form[id^=GPAdvancedForm-] ul.search-result a { + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + margin: 0; + padding: 0; + background: none; + display: block; +} +form[id^=GPAdvancedForm-] ul.search-result a:before { + margin-right: 0.5em; +} +form[id^=GPAdvancedForm-] ul.search-result .hidden { + display: none; +} +form[id^=GPAdvancedForm-] ul.search-result button { + flex: 1 1 0; + display: flex; + justify-content: center; + align-items: center; +} +form[id^=GPAdvancedForm-] ul.search-result .search-result-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + flex-direction: column; + text-align: center; +} +form[id^=GPAdvancedForm-] ul.search-result .search-result-actions:hover { + background-color: transparent; +} +form[id^=GPAdvancedForm-] ul.search-result .fr-message--error { + color: var(--background-flat-warning); + white-space: normal; +} + +form[id^=GPAdvancedForm-] select option.option { + background-color: var(--background-action-low-blue-france); +} + + +/* Search parcels */ +form[id^=GPAdvancedForm-ParcelAdvancedSearch-] .GPautoCompleteList { + display: none; + z-index: 1; + padding: 0; +} +form[id^=GPAdvancedForm-ParcelAdvancedSearch-] input[aria-expanded="true"] ~ .GPautoCompleteList { + display: block; +} + +form[id^=GPAdvancedForm-ParcelAdvancedSearch-] .GPautoCompleteList li { + height: auto; +} + +form[id^=GPAdvancedForm-ParcelAdvancedSearch-] [id^=ParcelAdvancedSearch-section] option:first-child{ + color: #aaa; + color: var(--text-disabled-grey); + font-style: italic; +} +form[id^=GPAdvancedForm-ParcelAdvancedSearch-] .fr-select-group:has(select:disabled) label, +form[id^=GPAdvancedForm-ParcelAdvancedSearch-] .fr-input-group:has(input:disabled) label { + color: var(--text-disabled-grey); +} + diff --git a/src/packages/CSS/Controls/SearchEngine/GPFadvancedSearchEngineStyle.css b/src/packages/CSS/Controls/SearchEngine/GPFadvancedSearchEngineStyle.css new file mode 100644 index 000000000..96ff1301e --- /dev/null +++ b/src/packages/CSS/Controls/SearchEngine/GPFadvancedSearchEngineStyle.css @@ -0,0 +1,5 @@ +/** STYLE NON DSFR RECHERCHE AVANCEE **/ + +div[id^=GPsearchEngine-Advanced] { + width:312px; +} \ No newline at end of file diff --git a/src/packages/CSS/Controls/SearchEngine/GPFsearchEngine.css b/src/packages/CSS/Controls/SearchEngine/GPFsearchEngine.css index eb32455c5..d9e23f8c3 100644 --- a/src/packages/CSS/Controls/SearchEngine/GPFsearchEngine.css +++ b/src/packages/CSS/Controls/SearchEngine/GPFsearchEngine.css @@ -1,10 +1,5 @@ /* SEARCH ENGINE */ -[id^="GPsearchEngine-"] { - top: 8px; - left: 46px; -} - [id^="GPsearchEngine-"] { /* dsfr */ display: flex; @@ -62,6 +57,7 @@ form[id^=GPsearchInput-] { display: inline-block; position: relative; overflow: hidden; + width: 100%; transition: max-width 0.5s ease-out 0s; } @@ -134,9 +130,6 @@ form[id^=GPadvancedSearchForm]:has(option[value="Coordinates"]:checked) > input[ /* Simple search input */ -[id^="GPsearchInput"] {} - - [id^="GPshowSearchDiv"]{ display: flex; } @@ -164,6 +157,7 @@ form[id^=GPadvancedSearchForm]:has(option[value="Coordinates"]:checked) > input[ [id^="GPautoCompleteList"] { margin-left: 33px; + z-index: 2; } [id^="GPautoCompleteList"] { @@ -187,3 +181,127 @@ form[id^=GPadvancedSearchForm]:has(option[value="Coordinates"]:checked) > input[ } } + +[id^="GPsearchEngine"] .GPautoCompleteContainer { + /* position: absolute; */ + box-shadow: 0 2px 6px 0 rgba(0, 0, 18, 0.16); + list-style: none; + overflow: hidden; + flex-direction: column; + width: 100%; + z-index: 1; +} +[id^="GPsearchEngine"] ul li { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +[id^=GPsearchEngine-].ol-collapsed form[id^=GPsearchInput-Base-] { + overflow: hidden; +} +[id^=GPsearchEngine-] form[id^=GPsearchInput-Base-] { + overflow: visible; +} + +[id^="GPsearchEngine"] { + display: flex; + flex-direction: column; + padding: 0; +} + +[id^="GPsearchEngine"] form[id^=GPsearchInput-Base-].GPSearchBar > :first-child { + display: flex; + flex-direction: row; + padding: 0; +} + +[id^="GPsearchEngine"] form[id^=GPsearchInput-Base-] .GPOptionsContainer { + display: flex; + height: calc(100% - 2px); +} + +[id^="GPsearchEngine"] form[id^=GPsearchInput-Base-] .GPOptionsContainer button { + margin: 0.5rem; +} + +.GPautoCompleteHeader { + margin-bottom: 0.75rem; + padding: 0.75rem 0.75rem 0; +} + +[id^="GPsearchEngine"] form[id^=GPsearchInput-Base-] .GPautoCompleteHeader button { + /* margin: 12px 12px 0; */ + max-width: unset; +} +[id^="GPsearchEngine"] form[id^=GPsearchInput-Base-] .GPautoCompleteFooter button { + margin: 0 12px 12px; + max-width: unset; +} + +[id^="GPsearchEngine"] .GPautoCompleteContainer .GPautoCompleteHeader { + display: none; +} +[id^="GPsearchEngine"] .GPautoCompleteContainer[data-type="history"] .GPautoCompleteHeader { + display: block; +} + +/* Advanced search panel */ +[id^="GPsearchEngine-Advanced"] { + display: flex; + flex-direction: column; + max-height: calc(100% - 2rem); + overflow: hidden; +} +[id^="GPsearchEngine-Advanced"] [id^="GPsearchEngine-"]{ + max-height: unset; +} + +[id^="GPsearchEngine-Advanced"] [id^="GPsearchEngine-"] { + position: static; +} +[id^="GPsearchEngine-Advanced"] > div { + display: flex; + flex-direction: column; + width: 100%; +} +[id^="GPsearchEngine-Advanced"] > .GPAdvancedContainer { + display: none; + box-sizing: content-box; + max-width: 100%; + max-height: unset; + padding: 0.75rem 0.75rem 1px; + box-sizing: border-box; +} +[id^="GPsearchEngine-Advanced"]:has(.GPSearchEngine-advanced-btn[aria-expanded="true"]) > .GPAdvancedContainer { + display: flex; + z-index: 1; +} +[id^="GPsearchEngine-Advanced"] .GPSearchEngine-advanced-btn[aria-expanded="false"]::after { + transform: rotate(180deg); +} + +/* [id^="GPsearchEngine-Advanced"] .GPAdvancedContainer[data-open="true"] > button { + display: none; +} */ + +[id^="GPsearchEngine-Advanced"][data-open="true"] section { + overflow: hidden auto; +} +/* [id^="GPsearchEngine-Advanced"] [data-open="true"] section::before { + display: none; +} */ + +[id^="GPsearchEngine-Advanced"] section .fr-collapse { + box-sizing: border-box; +} + +[id^="GPsearchEngine-Advanced"] [id^="GPsearchInput-Base-"] .GPsearchInputText { + border-radius: 0.25rem 0.25rem 0 0; +} +[id^="GPsearchEngine-Advanced"] section [id^="GPsearchInput-Base-"] .GPsearchInputSubmit { + display: none; +} +[id^="GPsearchEngine-Advanced"] section [id^="GPsearchEngine-"] { + margin-top: 0.5rem; +} diff --git a/src/packages/CSS/Controls/SearchEngine/GPFsearchEngineStyle.css b/src/packages/CSS/Controls/SearchEngine/GPFsearchEngineStyle.css index eefaa8fed..7b0e974d5 100644 --- a/src/packages/CSS/Controls/SearchEngine/GPFsearchEngineStyle.css +++ b/src/packages/CSS/Controls/SearchEngine/GPFsearchEngineStyle.css @@ -3,6 +3,8 @@ [id^="GPsearchEngine-"] { flex-direction: row; align-items: center; + top: 8px; + left: 46px; } .position-container-top-left [id^="GPsearchEngine-"], @@ -159,10 +161,14 @@ dialog[id^=GPgeocodeResultsList] { width: 280px; position: absolute; max-height: 140px; - background-color: var(--background-default-grey); + background-color: #ffffff; } +[id^="GPsearchEngine-Advanced"] > .GPAdvancedContainer { + background-color: #ffffff; +} + div[id^=GPautoCompleteList] { top: 35px; } @@ -213,3 +219,24 @@ div[id^=GPgeocodeResults-] { [id^="GPautocompleteResultsLocation"] { padding: 0; } + +[id^="GPsearchEngine"] .GPautoCompleteContainer { + background-color: white; +} + +[id^="GPsearchEngine"] ul { + margin: 3px 0; + width: 100%; + padding: 0; +} + +[id^="GPsearchEngine"] ul li { + color: #5E5E5E; + padding: 6px 10px; +} + +[id^="GPsearchEngine"] ul li.active, +[id^="GPsearchEngine"] ul li:hover { + color: #000000; + background-color: #CEDBEF; +} \ No newline at end of file diff --git a/src/packages/CSS/Controls/SearchEngine/img/dsfr/bike-line.svg b/src/packages/CSS/Controls/SearchEngine/img/dsfr/bike-line.svg new file mode 100644 index 000000000..f05c49135 --- /dev/null +++ b/src/packages/CSS/Controls/SearchEngine/img/dsfr/bike-line.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/packages/CSS/Controls/SearchEngine/img/dsfr/history-line.svg b/src/packages/CSS/Controls/SearchEngine/img/dsfr/history-line.svg new file mode 100644 index 000000000..91df49d3d --- /dev/null +++ b/src/packages/CSS/Controls/SearchEngine/img/dsfr/history-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/packages/CSS/Controls/SearchEngine/img/dsfr/ign-mer.svg b/src/packages/CSS/Controls/SearchEngine/img/dsfr/ign-mer.svg new file mode 100644 index 000000000..7423e9e76 --- /dev/null +++ b/src/packages/CSS/Controls/SearchEngine/img/dsfr/ign-mer.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/packages/CSS/Controls/SearchEngine/img/dsfr/map-pin-add-fill.svg b/src/packages/CSS/Controls/SearchEngine/img/dsfr/map-pin-add-fill.svg new file mode 100644 index 000000000..13e803d55 --- /dev/null +++ b/src/packages/CSS/Controls/SearchEngine/img/dsfr/map-pin-add-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/packages/CSS/Controls/SearchEngine/img/dsfr/map-pin-add-line.svg b/src/packages/CSS/Controls/SearchEngine/img/dsfr/map-pin-add-line.svg new file mode 100644 index 000000000..4b9c407c7 --- /dev/null +++ b/src/packages/CSS/Controls/SearchEngine/img/dsfr/map-pin-add-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/packages/CSS/Controls/SearchEngine/img/dsfr/parking-box-line.svg b/src/packages/CSS/Controls/SearchEngine/img/dsfr/parking-box-line.svg new file mode 100644 index 000000000..a27d8b801 --- /dev/null +++ b/src/packages/CSS/Controls/SearchEngine/img/dsfr/parking-box-line.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/packages/CSS/Controls/SearchEngine/img/dsfr/plane-line.svg b/src/packages/CSS/Controls/SearchEngine/img/dsfr/plane-line.svg new file mode 100644 index 000000000..7a554dbd5 --- /dev/null +++ b/src/packages/CSS/Controls/SearchEngine/img/dsfr/plane-line.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/packages/CSS/Controls/SearchEngine/img/dsfr/subway-line.svg b/src/packages/CSS/Controls/SearchEngine/img/dsfr/subway-line.svg new file mode 100644 index 000000000..81d96f657 --- /dev/null +++ b/src/packages/CSS/Controls/SearchEngine/img/dsfr/subway-line.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/packages/CSS/Interactions/Drawing.css b/src/packages/CSS/Interactions/Drawing.css new file mode 100644 index 000000000..89d943816 --- /dev/null +++ b/src/packages/CSS/Interactions/Drawing.css @@ -0,0 +1,28 @@ +.ol-control.ol-drawing-info { + color: var(--light-decisions-text-text-default-grey, #3A3A3A); + /* 2.Corps de texte/XS - Texte mention/Desktop & Mobile - Regular */ + font-family: Marianne; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 20px; + + display: inline-flex; + padding: 8px 12px; + justify-content: center; + align-items: center; + gap: 4px; + + background-color: rgba(255, 255, 255, 0.8); + border-radius: 4px; + font-size: 12px; + + top: 4em; + left: 50%; + transform: translateX(-50%); + pointer-events: none!important; +} +.ol-control.ol-drawing-info:empty { + pointer-events: none!important; + display: none; +} diff --git a/src/packages/Controls/SearchEngine/AbstractAdvancedSearch.js b/src/packages/Controls/SearchEngine/AbstractAdvancedSearch.js new file mode 100644 index 000000000..010d37ac1 --- /dev/null +++ b/src/packages/Controls/SearchEngine/AbstractAdvancedSearch.js @@ -0,0 +1,189 @@ +// import CSS +import "../../CSS/Controls/SearchEngine/GPFadvancedSearchEngine.css"; +import Control from "../Control"; +import Logger from "../../Utils/LoggerByDefault"; +import Helper from "../../Utils/Helper"; +import Map from "ol/Map"; +var logger = Logger.getLogger("abstractAdvancedSearch"); + +// Typedefs partagés disponibles dans ./typedefs.js (AbstractAdvancedSearchOptions, ...) + +/** + * @classdesc + * Contrôle de base pour les recherches avancées (formulaires spécialisés). + * + * Fournit la structure HTML du formulaire, la gestion des inputs et + * les événements d'effacement et de soumission. Les contrôles spécialisés + * doivent surcharger addInputs() et _onSearch(). + * + * @alias ol.control.AbstractAdvancedSearch + * @module AbstractAdvancedSearch +*/ +class AbstractAdvancedSearch extends Control { + + /** + * @constructor + * @param {AbstractAdvancedSearchOptions} options Options du constructeur + * + */ + constructor (options) { + options = options || {}; + // call ol.control.Control constructor + super(options); + + /** + * Nom de la classe (heritage) + * @private + */ + this.CLASSNAME = "AbstractAdvancedSearch"; + + // initialisation du composant + this.initialize(options); + + this.element = this._initContainer(options); + this._initEvents(options); + } + + setMap (map) { + super.setMap(map); + this.inputs.forEach(input => { + if (input.setMap) { + input.setMap(map); + } + }); + // Replace les boutons à la fin + this.element.appendChild(this.btnGroup); + } + + /** + * Initialise le contrôle (appelé par le constructeur). + * @param {AbstractAdvancedSearchOptions} options Options d'initialisation + * @protected + */ + initialize (options) { + if (!options.name) { + throw new SyntaxError("`name` is mandatory"); + } else { + this.name = options.name; + } + + this.inputs = []; + } + + /** + * Retourne le nom du la recherche avancée + * @returns {String} Nom de la recherche avancée + */ + getName () { + return this.name; + } + + /** + * Retourne le formulaire de la recherche avancée + * @returns {HTMLFormElement} Formulaire de la recherche + */ + getContent () { + return this.element; + } + + /** + * Crée le conteneur DOM du formulaire. + * + * @param {AbstractAdvancedSearchOptions} options Options d'initialisation + * @returns {HTMLFormElement} Élément du formulaire + * @protected + */ + _initContainer (options) { + let element = document.createElement("form"); + element.className = "GPForm gpf-advanced-search-form"; + element.id = Helper.getUid("GPAdvancedForm-" + this.CLASSNAME + "-"); + + let fieldset = document.createElement("fieldset"); + fieldset.className = "GPFieldset fr-fieldset gpf-advanced-search-fieldset"; + fieldset.id = Helper.getUid("GPAdvancedFieldset-"); + + this.addInputs(); + this.inputs.forEach((elem) => { + if (elem instanceof Control) { + elem.setTarget(element); + } else { + element.appendChild(elem); + } + }); + + // element.appendChild(fieldset); + + const btnGroup = this.btnGroup = document.createElement("div"); + btnGroup.className = "GPFormFooter"; + + const eraseBtn = this.eraseBtn = document.createElement("button"); + eraseBtn.type = "reset"; + eraseBtn.className = "GPBtn gpf-btn fr-btn fr-btn--tertiary"; + eraseBtn.id = Helper.getUid("GPEraseBtn-"); + eraseBtn.textContent = "Effacer"; + + const searchBtn = this.searchBtn = document.createElement("button"); + searchBtn.className = "GPBtn gpf-btn fr-btn"; + searchBtn.id = Helper.getUid("GPSearchBtn-"); + searchBtn.type = "submit"; + searchBtn.textContent = "Rechercher"; + searchBtn.setAttribute("form", element.id); + + btnGroup.appendChild(eraseBtn); + btnGroup.appendChild(searchBtn); + + element.appendChild(btnGroup); + + return element; + } + + /** + * Ajoute des éléments d'input dans la collection `this.inputs`. + * Cette méthode est abstraite et doit être surchargée par les implémentations spécifiques. + * @protected + * @abstract + */ + addInputs () { + + } + + + /** Add event listeners + * @param {AbstractAdvancedSearchOptions} options - constructor options + * @protected + */ + _initEvents (options) { + this.eraseBtn.onclick = this._onErase.bind(this); + this.element.onsubmit = this._onSearch.bind(this); + } + + /** + * Réinitialise les champs du formulaire. + * @param {PointerEvent} e Événement d'effacement + * @protected + */ + _onErase (e) { + e.preventDefault(); + this.getContent().querySelectorAll("input").forEach(input => { + input.value = ""; + }); + } + + /** + * Traitement lors de la soumission du formulaire (recherche). + * Doit être surchargé par les contrôles spécifiques pour lancer la recherche. + * @param {SubmitEvent|PointerEvent} e Évènement de soumission / recherche + * @protected + */ + _onSearch (e) { + e.preventDefault(); + } + +} + +export default AbstractAdvancedSearch; + +// Expose AbstractAdvancedSearch as ol.control.AbstractAdvancedSearch (for a build bundle) +if (window.ol && window.ol.control) { + window.ol.control.AbstractAdvancedSearch = AbstractAdvancedSearch; +} diff --git a/src/packages/Controls/SearchEngine/CoordinateAdvancedSearch.js b/src/packages/Controls/SearchEngine/CoordinateAdvancedSearch.js new file mode 100644 index 000000000..b5a1b48c5 --- /dev/null +++ b/src/packages/Controls/SearchEngine/CoordinateAdvancedSearch.js @@ -0,0 +1,680 @@ +// import CSS +import AbstractAdvancedSearch from "./AbstractAdvancedSearch"; +import Logger from "../../Utils/LoggerByDefault"; +import CRS from "../../CRS/CRS"; +import Helper from "../../Utils/Helper"; +import { + transform as olProjTransform, + get as olProjGet +} from "ol/proj"; +import MathUtils from "../../Utils/MathUtils"; +import Feature from "ol/Feature.js"; +import Point from "ol/geom/Point.js"; + +let logger = Logger.getLogger("abstractAdvancedSearch"); + +/** + * @classdesc + * Contrôle de recherche avancée par coordonnées + * (saisie pour différents systèmes de coordonnées et unités correspondantes). + * + * @alias ol.control.CoordinateAdvancedSearch + * @module CoordinateAdvancedSearch + */ +class CoordinateAdvancedSearch extends AbstractAdvancedSearch { + + /** + * Constructeur. + * + * @constructor + * @param {CoordinateAdvancedSearchOptions} [options] Options du contrôle + */ + constructor (options) { + super(options); + + this.element.dataset.unitType = this.get("unitType"); + this.element.dataset.unit = this.unit.value; + } + + /** + * @override + * @protected + * @param {CoordinateAdvancedSearchOptions} options Options d'initialisation + */ + initialize (options) { + if (!options.name) { + options.name = "Coordonnées"; + } + super.initialize(options); + + this._initCoordinateSearchSystems(options); + this._initCoordinateSearchUnits(options); + + this._boundOnLonLatBeforeInput = this._onlonLatBeforeInput.bind(this); + this._boundOnLonLatInput = this._onlonLatInput.bind(this); + + this._currentCoordinateSystem = this._coordinateSearchSystems[0]; + this._currentUnit = this._coordinateSearchUnits[this._currentCoordinateSystem.type]; + + this.set("unitType", this._currentCoordinateSystem.type); + this.set("unit", this._currentUnit[0].code); + + /** + * Nom de la classe (heritage) + * @private + */ + this.CLASSNAME = "CoordinateAdvancedSearch"; + } + + /** + * @private + * @param {CoordinateAdvancedSearchOptions} [options] Options d'initialisation possibles (options.coordinateSearch.systems) + */ + _initCoordinateSearchSystems (options) { + this._coordinateSearchSystems = []; + // on donne la possibilité à l'utilisateur de modifier + // la liste des systèmes à afficher + // Ex. this.options.coordinateSearch.systems + + // systemes de projection disponible par defaut + let projectionSystemsByDefault = [{ + label : "G\u00e9ographique", + crs : "EPSG:4326", + type : "Geographical" + }, { + label : "Web Mercator", + crs : "EPSG:3857", + type : "Metric" + }, { + label : "Lambert 93", + crs : "EPSG:2154", + type : "Metric" + }]; + + if (options && options.coordinateSearch && options.coordinateSearch.systems) { + let systems = options.coordinateSearch.systems; + // on ajoute les definitions d'un systeme de reference fournies par l'utilisateur + for (let i = 0; i < systems.length; i++) { + let sys = systems[i]; + this._setSystem(sys); + } + } + + // on ajoute les systèmes de projections par défaut + if (this._coordinateSearchSystems.length === 0) { + for (let j = 0; j < projectionSystemsByDefault.length; j++) { + this._setSystem(projectionSystemsByDefault[j]); + } + } + } + + /** + * @private + * @param {CoordinateAdvancedSearchOptions} [options] Options d'initialisation possibles (options.coordinateSearch.units) + */ + _initCoordinateSearchUnits (options) { + this._coordinateSearchUnits = []; + // on donne la possibilité à l'utilisateur de modifier + // la liste des unités à afficher + // Ex. + // this.options.units : ["DEC", "DMS"] + + // unités disponible par defaut + let projectionUnitsByDefault = { + Geographical : [{ + code : "DEC", + label : "Degrés décimaux", + format : MathUtils.coordinateToDecimal + }, { + code : "DMS", + label : "Degrés sexagésimaux", + format : MathUtils.coordinateToDMS + }], + Metric : [{ + code : "M", + label : "Mètres", + format : MathUtils.coordinateToMeter + }, { + code : "KM", + label : "Kilomètres", + format : MathUtils.coordinateToKMeter + }] + }; + + if (options && options.coordinateSearch && options.coordinateSearch.units) { + let units = options.coordinateSearch.units; + for (let type in projectionUnitsByDefault) { + if (projectionUnitsByDefault.hasOwnProperty(type)) { + let found = false; + for (let j = 0; j < projectionUnitsByDefault[type].length; j++) { + let obj = projectionUnitsByDefault[type][j]; + for (let i = 0; i < units.length; i++) { + let unit = units[i]; + if (obj.code === unit) { + found = true; + if (!this._coordinateSearchUnits[type]) { + this._coordinateSearchUnits[type] = []; + } + this._coordinateSearchUnits[type].push(obj); + } + } + } + if (!found) { + this._coordinateSearchUnits[type] = projectionUnitsByDefault[type]; + } + } + } + } + + // au cas où... + if (typeof this._coordinateSearchUnits === "object" && Object.keys(this._coordinateSearchUnits).length === 0) { + this._coordinateSearchUnits = projectionUnitsByDefault; + } + } + + /** + * Définit un système de projection supplémentaire et charge sa définition CRS si nécessaire. + * + * @private + * @param {CoordinateSearchSystem} system Description du système de projection + * @returns {void} + */ + _setSystem (system) { + if (typeof system !== "object") { + logger.log("[ERROR] CoordinateAdvancedSearch:_setSystem - system parameter should be an object"); + return; + } + if (!system.crs) { + logger.error("crs not defined !"); + return; + } + if (!system.label) { + logger.warn("crs label not defined, use crs code by default."); + system.label = system.crs; + } + if (!system.type) { + logger.warn("type srs not defined, use 'Metric' by default."); + system.type = "Metric"; + } + + // chargement de la definition de la projection + // même si déjà chargé... + CRS.loadByName(system.crs); + + if (!olProjGet(system.crs)) { + logger.error("crs '{}' not available into proj4 definitions !", system.crs); + return; + } + + // add system to control systems + for (var j = 0; j < this._coordinateSearchSystems.length; j++) { + var obj = this._coordinateSearchSystems[j]; + if (system.crs === obj.crs) { + // warn user + logger.info("crs '{}' already configured", obj.crs); + } + } + system.code = this._coordinateSearchSystems.length; + this._coordinateSearchSystems.push(system); + } + + /** + * Crée un conteneur d'étiquette pour un élément d'input. + * + * @private + * @param {String} text Texte de l'étiquette + * @param {String} type Classe CSS du conteneur + * @param {HTMLElement} [input] Élément input à rattacher + * @param {Boolean} [mandatory=false] Indique si le champ est obligatoire + * @returns {HTMLElement} Élément conteneur (div) + */ + _getLabelContainer (text, type, input, mandatory = false) { + const container = document.createElement("div"); + container.className = type; + const label = this._createLabel(text, mandatory); + container.appendChild(label); + if (input) { + label.setAttribute("for", input.id); + container.appendChild(input); + } + return container; + } + + /** + * Crée un élément label. + * + * @private + * @param {String} text Texte du label + * @param {Boolean} [mandatory=false] Ajoute un astérisque si vrai + * @returns {HTMLLabelElement} Élément label créé + */ + _createLabel (text, mandatory = false) { + const label = document.createElement("label"); + label.className = "fr-label"; + const star = mandatory ? "*" : ""; + label.textContent = text + star; + return label; + } + + /** + * Met à jour le texte d'un label existant. + * + * @private + * @param {String} text Nouveau texte + * @param {HTMLElement} container Conteneur contenant le label à mettre à jour + * @param {Boolean} [mandatory=false] Indique si le champ est obligatoire + */ + _updateLabel (text, container, mandatory = false) { + const label = container.querySelector("label"); + const star = mandatory ? "*" : ""; + label.innerText = text + star; + } + + /** + * @override + * @protected + */ + addInputs () { + super.addInputs(); + + // Indication champs obligatoire + const div = document.createElement("div"); + const mandatory = document.createElement("span"); + mandatory.className = "GPLabelHint fr-hint-text"; + mandatory.textContent = "* Tous les champs sont obligatoires"; + div.appendChild(mandatory); + this.inputs.push(div); + + // Système de référence + const systemSelect = this.system = document.createElement("select"); + systemSelect.className = "fr-select"; + systemSelect.id = systemSelect.name = Helper.getUid("CoordinateAdvancedSearch-system-"); + const systemContainer = this._getLabelContainer("Système de référence", "fr-select-group", systemSelect, true); + this.inputs.push(systemContainer); + + this._coordinateSearchSystems.forEach((elem, index) => { + const option = document.createElement("option"); + option.value = index; + option.selected = !index; + option.innerText = elem.label; + systemSelect.appendChild(option); + }); + + // Unité correspondantes + const unitSelect = this.unit = document.createElement("select"); + unitSelect.className = "fr-select"; + unitSelect.id = unitSelect.name = Helper.getUid("CoordinateAdvancedSearch-unit-"); + const unitContainer = this._getLabelContainer("Unité", "fr-select-group", unitSelect, true); + this.inputs.push(unitContainer); + + for (let j = 0; j < this._currentUnit.length; j++) { + const unit = this._currentUnit[j]; + const option = document.createElement("option"); + option.value = (unit.code) ? unit.code : j;; + option.textContent = unit.label || j; + unitSelect.appendChild(option); + } + + // Longitude et Latitude / X et Y + const values = this.lonLatInputs = document.createElement("div"); + values.className = "GPLonLatInputs"; + + // Longitude / X + const lonWrapper = this.lon = this.createCoordinateInput("lon"); + values.appendChild(lonWrapper); + + // Latitude / Y + const latWrapper = this.lat = this.createCoordinateInput("lat"); + values.appendChild(latWrapper); + + this.inputs.push(values); + } + + /** + * Crée le wrapper (label + input + masques) pour une composante de coordonnées (lon/lat). + * + * @private + * @param {"lon"|"lat"} type Type de composante ("lon" ou "lat") + * @returns {HTMLElement} Wrapper contenant l'input et éléments associés + */ + createCoordinateInput (type) { + // type = "lon" or "lat" + const wrapper = document.createElement("div"); + wrapper.className = "GPCoordinateWrapper"; + + // Label + const labelText = type === "lon" ? "Longitude" : "Latitude"; + const label = this._createLabel(labelText, true); + wrapper.appendChild(label); + + // Input container + const inputContainer = document.createElement("div"); + inputContainer.className = "GPCoordinateInputs"; + wrapper.appendChild(inputContainer); + + // Input + const div = document.createElement("div"); + const input = document.createElement("input"); + input.className = "GPCoordinateInput fr-input"; + input.type = "text"; + input.name = type; + input.inputMode = "numeric"; + input.autocomplete = "off"; + input.required = true; + input.id = Helper.getUid(`CoordinateAdvancedSearch-${type}-`); + div.appendChild(input); + inputContainer.appendChild(div); + + // Lie le label à l'input + label.setAttribute("for", input.id); + + // Mask + const mask = document.createElement("div"); + mask.className = "display-mask"; + mask.textContent = "__°__'__\""; + // inputContainer.appendChild(mask); + div.appendChild(mask); + + // Cardinal points + inputContainer.appendChild(this._createSelectCardinals(type)); + + return wrapper; + } + + /** + * Crée la liste des points cardinaux (N/S ou O/E) pour un champ de coordonnées. + * + * @private + * @param {String} baseName "lon" ou "lat" + * @returns {HTMLSelectElement|undefined} Élément select des cardinaux ou undefined si non applicable + */ + _createSelectCardinals (baseName) { + const options = { + lon : ["O", "E"], + lat : ["N", "S"], + }; + + if (!options[baseName]) { + return; + } else { + const cardinals = document.createElement("select"); + cardinals.className = "fr-select"; + cardinals.id = cardinals.name = Helper.getUid("CoordinateAdvancedSearch-cardinal-"); + + options[baseName].forEach(value => { + const option = document.createElement("option"); + option.value = option.textContent = value; + cardinals.appendChild(option); + }); + + return cardinals; + } + } + + /** + * @override + * @protected + * @param {CoordinateAdvancedSearchOptions} options Options d'initialisation + */ + _initEvents (options) { + super._initEvents(options); + + this.system.addEventListener("change", this._updateSystem.bind(this)); + + this.unit.addEventListener("change", this._updateUnits.bind(this)); + + this.on("change:unitType", this._updateInputsLabel.bind(this)); + + this.on("change:unit", this._updateInputs.bind(this)); + } + + /** + * Met à jour le système de référence sélectionné et réinitialise les unités disponibles. + * + * @private + * @param {Event} e Événement change provenant du select système + */ + _updateSystem (e) { + const crs = this._coordinateSearchSystems[e.target.value].crs; + if (crs !== this._currentCoordinateSystem.crs) { + this._currentCoordinateSystem = this._coordinateSearchSystems[e.target.value]; + this._currentUnit = this._coordinateSearchUnits[this._currentCoordinateSystem.type]; + this.unit.replaceChildren(); + for (let j = 0; j < this._currentUnit.length; j++) { + const unit = this._currentUnit[j]; + const option = document.createElement("option"); + option.value = (unit.code) ? unit.code : j; + option.textContent = unit.label || j; + this.unit.appendChild(option); + } + e.target.closest("form").dataset.unit = this.unit.value; + this.set("unit", this.unit.value); + e.target.closest("form").dataset.unitType = this._currentCoordinateSystem.type; + this.set("unitType", this._currentCoordinateSystem.type); + } + } + + /** + * Met à jour l'unité sélectionnée et stocke la valeur dans le dataset du formulaire. + * + * @private + * @param {Event} e Événement change provenant du select unité + */ + _updateUnits (e) { + const value = e.target.value; + if (this.get("unit") !== value) { + e.target.closest("form").dataset.unit = value; + this.set("unit", value); + } + } + + /** + * Met à jour les labels des inputs en fonction du type d'unités (géographique vs métrique). + * + * @private + */ + _updateInputsLabel () { + const unitType = this.get("unitType"); + const degree = unitType === "Geographical"; + const labels = { + "lon" : degree ? "Longitude" : "X", + "lat" : degree ? "Latitude" : "Y", + }; + this._updateLabel(labels.lon, this.lon, true); + this._updateLabel(labels.lat, this.lat, true); + + // Reset la valeur + this.lon.querySelector("input").value = ""; + this.lat.querySelector("input").value = ""; + } + + /** + * Met à jour les inputs lorsque l'unité change (conversion ou activation du masquage pour DMS). + * + * @private + */ + _updateInputs () { + const unit = this.get("unit"); + let factor = 1; + if (this.get("unitType") === "Metric") { + factor = unit === "KM" ? 0.001 : 1000; + } + // TODO : Faire convertion ?! + if (unit === "DMS") { + this.lonLatInputs.querySelectorAll("input").forEach(input => { + input.value = ""; + input.minLength = "6"; + input.maxLength = "6"; + input.addEventListener("beforeinput", this._boundOnLonLatBeforeInput); + input.addEventListener("input", this._boundOnLonLatInput); + }); + } else { + this.lonLatInputs.querySelectorAll("input").forEach(input => { + if (unit === "DEC") { + input.value = ""; + } else { + input.value = input.value === "" || isNaN(input.value) ? "" : parseFloat(input.value) * factor; + } + input.removeAttribute("minLength"); + input.removeAttribute("maxLength"); + input.removeEventListener("beforeinput", this._boundOnLonLatBeforeInput); + input.removeEventListener("input", this._boundOnLonLatInput); + }); + } + // Réinitialise le mask de l'input (dans tous les cas) + this.getContent().querySelectorAll(".display-mask").forEach(mask => { + mask.textContent = "__°__'__\""; + }); + } + + /** + * Validation avant saisie pour les formats DMS : n'autorise que les chiffres (évite les lettres). + * + * @private + * @param {InputEvent} e Événement beforeinput + */ + _onlonLatBeforeInput (e) { + // const regex = /^(?:\d{2}°\d{2}'\d{2}(?:"|''))$|^\d{6}$/; + const regex = /^\d+$/; + // Vérifie si c'est un chiffre + // TODO : améliorer cela ? vis à vis du insertLineBreak (touche entrée) + if (e.inputType.startsWith("insert") && e.inputType != "insertLineBreak" && !regex.test(e.data)) { + e.preventDefault(); + } + } + + /** + * Formate une chaîne de 6 caractères en DMS (DD°MM'SS"). + * + * @private + * @param {String} value Valeur brute (6 caractères numériques) + * @returns {String} Chaîne formatée (ex. "12°34'56\"") + */ + _format (value) { + const v = value.padEnd(6, "_"); + return `${v.slice(0,2)}°${v.slice(2,4)}'${v.slice(4,6)}"`; + } + + /** + * Met à jour l'affichage du masque pendant la saisie DMS. + * + * @private + * @param {InputEvent} e Événement input + */ + _onlonLatInput (e) { + console.log("on lon lat inputs"); + const value = e.target.value; + const mask = e.target.parentElement.querySelector(".display-mask"); + mask.textContent = this._format(value); + } + + /** + * @override + * @protected + * @param {PointerEvent} e Événement de soumission + */ + _onSearch (e) { + super._onSearch(e); + // Récupère les valeurs des inputs + let lon = this.lon.querySelector("input").value; + let lat = this.lat.querySelector("input").value; + if (this.get("unit") === "DMS") { + // Transforme les DMS en degrés décimaux + lon = MathUtils.dmsToDecimal( + parseInt(lon.substring(0, 2)), + parseInt(lon.substring(2, 4)), + parseInt(lon.substring(4, 6)), + this.lon.querySelector("select").value + ); + lat = MathUtils.dmsToDecimal( + parseInt(lat.substring(0, 2)), + parseInt(lat.substring(2, 4)), + parseInt(lat.substring(4, 6)), + this.lat.querySelector("select").value + ); + } else if (this.get("unit") === "KM") { + // Transforme les kilomètres en mètre + lon = parseFloat(lon) * 1000; + lat = parseFloat(lat) * 1000; + } else { + // Récupère les valeurs en float + lon = parseFloat(lon); + lat = parseFloat(lat); + } + + // Projette les coordonnées dans les coordonnées de la carte + let coords = [lon, lat]; + const mapProj = this.getMap().getView().getProjection().getCode(); + const currentProj = this._currentCoordinateSystem.crs; + if (mapProj !== currentProj) { + coords = olProjTransform(coords, currentProj, mapProj); + } + + const geom = new Point(coords); + const f = new Feature({ geometry : geom }); + // Ajout des coordonnées pour le popup + f.set("infoPopup", this._createInfoPopup(lon, lat)); + + this.dispatchEvent({ + type : "search", + result : f, + }); + } + + /** + * Construit le contenu HTML à afficher dans la popup à partir des coordonnées fournies. + * + * @private + * @param {Number|String} lon Valeur X / longitude + * @param {Number|String} lat Valeur Y / latitude + * @returns {String} Contenu HTML (string) à injecter dans la popup + */ + _createInfoPopup (lon, lat) { + let x, y, valueX, valueY; + if (this.get("unitType") === "Geographical") { + x = "Longitude"; + y = "Latitude"; + + if (this.get("unit") === "DMS") { + const lon = this.lon.querySelector("input").value; + const lonCardinal = this.lon.querySelector("select").value; + valueX = `${parseInt(lon.substring(0, 2))}°${parseInt(lon.substring(2, 4))}'${parseInt(lon.substring(4, 6))}" ${lonCardinal}`; + + const lat = this.lat.querySelector("input").value; + const latCardinal = this.lat.querySelector("select").value; + valueY = `${parseInt(lat.substring(0, 2))}°${parseInt(lat.substring(2, 4))}'${parseInt(lat.substring(4, 6))}" ${latCardinal}`; + } else { + valueX = `${lon} °`; + valueY = `${lat} °`; + } + } else { + x = "X"; + y = "Y"; + valueX = `${lon} ${this.get("unit").toLowerCase()}`; + valueY = `${lat} ${this.get("unit").toLowerCase()}`; + } + const infoPopup = `${x} : ${valueX}
${y} : ${valueY}`; + return infoPopup; + } + + + /** + * Réinitialise les champs du formulaire. + * @param {PointerEvent} e Événement d'effacement + * @protected + */ + _onErase (e) { + super._onErase(e); + this.getContent().querySelectorAll(".display-mask").forEach(mask => { + mask.textContent = "__°__'__\""; + }); + } + +} + +export default CoordinateAdvancedSearch; + +// Expose CoordinateAdvancedSearch as ol.control.CoordinateAdvancedSearch (for a build bundle) +if (window.ol && window.ol.control) { + window.ol.control.CoordinateAdvancedSearch = CoordinateAdvancedSearch; +} diff --git a/src/packages/Controls/SearchEngine/InseeAdvancedSearch.js b/src/packages/Controls/SearchEngine/InseeAdvancedSearch.js new file mode 100644 index 000000000..356d49e13 --- /dev/null +++ b/src/packages/Controls/SearchEngine/InseeAdvancedSearch.js @@ -0,0 +1,148 @@ +// import CSS +import AbstractAdvancedSearch from "./AbstractAdvancedSearch"; +import Logger from "../../Utils/LoggerByDefault"; +import SearchEngineGeocodeIGN from "./SearchEngineGeocodeIGN"; +import InseeSearchService from "../../Services/InseeSearchService"; + +var logger = Logger.getLogger("abstractAdvancedSearch"); + +// Voir les typedefs réutilisables (types partagés) dans le dossier SearchEngine si nécessaire. + +/** + * @classdesc + * Contrôle de recherche avancée pour les codes INSEE. + * + * @alias ol.control.InseeAdvancedSearch + * @module InseeAdvancedSearch + * @extends AbstractAdvancedSearch + */ +class InseeAdvancedSearch extends AbstractAdvancedSearch { + + /** + * @constructor + * @param {AbstractAdvancedSearchOptions} [options] - Options du contrôle. + */ + constructor (options) { + super(options); + + this.inseeInput.on("search", function (e) { + if (e.result) { + this.dispatchEvent(e); + } else { + // Pas de réponse, on affiche un message d'erreur + this.inseeInput.addMessage("Ce code INSEE est introuvable."); + } + }.bind(this)); + } + + /** + * + * @param {AbstractAdvancedSearchOptions} options Options de recherche avancée + * @override + * @protected + */ + initialize (options) { + if (!options.name) { + options.name = "Code INSEE"; + } + super.initialize(options); + + this._inputPattern = /^(\d\d|2[A,B,a,b])\d{3}$/; + + /** + * Nom de la classe (heritage) + * @private + */ + this.CLASSNAME = "InseeAdvancedSearch"; + } + + /** + * Ajoute les inputs spécifiques au contrôle (surcouche du parent). + * Crée et configure l'input INSEE. + * @protected + * @returns {void} + */ + addInputs () { + super.addInputs(); + + let inseeInput = this.inseeInput = new SearchEngineGeocodeIGN({ + label : "Code INSEE", + hint : "Format attendu INSEE : 5 chiffres, selon le code officiel géographique (COG)", + searchService : new InseeSearchService({ + autocomplete : false, + searchOptions : { + serviceOptions : { + fields : ["postcode"], + }, + }, + }) + }); + + this.inseeInput.input.title = "Code INSEE sur 5 caractères"; + + // TODO : laisser validation directe par l'ordi ? + // this.inseeInput.input.pattern = "^(\\d\\d|2[A,B,a,b])\\d{3}$"; + // this.inseeInput.input.maxLength = 5; + // this.inseeInput.input.minLength = 5; + + this.inputs.push(inseeInput); + } + + /** + * Initialise les événements DOM spécifiques. + * @protected + * @param {AbstractAdvancedSearchOptions} options - Options d'initialisation (transmises depuis le parent). + * @override + */ + _initEvents (options) { + super._initEvents(options); + + this.inseeInput.input.onkeydown = function (e) { + if (e.key === "Enter") { + this.element.requestSubmit(this.searchBtn); + } + }.bind(this); + } + + /** + * Permet de supprimer les messages d'erreur, en plus du fonctionnement initial. + * + * @protected + * @override + */ + _onErase (e) { + super._onErase(e); + this.inseeInput.removeMessages(); + } + + /** + * Handler de la recherche déclenchée (bouton / submit). + * Valide le code INSEE puis lance la recherche via le SearchEngine associé. + * @protected + * @param {PointerEvent|Event} e - Événement de recherche. + */ + _onSearch (e) { + super._onSearch(e); + const insee = this.inseeInput.input.value; + + if (this._inputPattern.test(insee)) { + this.inseeInput.removeMessages(); + this.inseeInput.search({ + location : insee, + filters : { + cityCode : insee, + } + }); + } else { + this.inseeInput.addMessage("Le champs INSEE n'est pas valide (texte de 5 chiffres)."); + } + } + +} + +export default InseeAdvancedSearch; + +// Expose InseeAdvancedSearch as ol.control.InseeAdvancedSearch (for a build bundle) +if (window.ol && window.ol.control) { + window.ol.control.InseeAdvancedSearch = InseeAdvancedSearch; +} diff --git a/src/packages/Controls/SearchEngine/LocationAdvancedSearch.js b/src/packages/Controls/SearchEngine/LocationAdvancedSearch.js new file mode 100644 index 000000000..d5e978089 --- /dev/null +++ b/src/packages/Controls/SearchEngine/LocationAdvancedSearch.js @@ -0,0 +1,511 @@ +import Helper from "../../Utils/Helper"; +import AbstractAdvancedSearch from "./AbstractAdvancedSearch"; +import IGNSearchService from "../../Services/IGNSearchService"; + +/** + * @classdesc + * Recherche avancée par lieu / toponyme (formulaire pour filtrer par type, code postal, INSEE). + * + * @alias ol.control.LocationAdvancedSearch + * @module LocationAdvancedSearch + */ +class LocationAdvancedSearch extends AbstractAdvancedSearch { + + /** + * Constructeur + * @constructor + * @param {AbstractAdvancedSearchOptions} options Options du constructeur + * @param {String} [options.name="Lieux et toponymes"] Nom du contrôle + * @param {Array|Object} [options.typeList] Liste des types de lieux (catégories) ou objet clé/valeur avec tableau de sous-catégories + * @extends {ol.control.AbstractAdvancedSearch} + */ + constructor (options) { + options = options || {}; + + options.name = options.name || "Lieux et toponymes"; + + // call ol.control.Control constructor + super(options); + + // Search service + this.searchService = new IGNSearchService({ + index : "poi", + limit : 10, + returnTrueGeometry : true + }); + + // Prevent popup validation + this.element.setAttribute("novalidate", ""); + } + + /** + * @override + * @protected + * @param {AbstractAdvancedSearchOptions} options Options du constructeur + */ + _initEvents (options) { + super._initEvents(options); + + this.on("expand", e => { + if (e.expanded) { + setTimeout(() => this.searchInput.focus(), 300); + } + this.searchResult.innerHTML = ""; + }); + + // Do something on search (when ready) + setTimeout(() => { + this.searchService.on("search", e => this.handleSearch(e)); + this.searchService.on("error", e => this.handleError(e)); + }); + } + + /** Gère les erreurs de recherche. + * @private + * @param {Event} e Événement d'erreur + */ + handleError (e) { + this.handleSearch({ nbResults : 0 }); + } + + /** + * Gère les résultats de la recherche. + * @private + * @param {Event} e Événement de recherche contenant les résultats + */ + handleSearch (e) { + // Clear previous results + if (!e.multi) { + this.searchResult.innerHTML = ""; + } + // Format output + if (e.nbResults === 1) { + const attr = e.attr || this.searchService.getResult(0).placeAttributes; + ["postcode","citycode","city","category"].forEach(field => { + attr[field] = attr[field] || []; + }); + const info = { + infoPopup : "" + attr.toponym + "
" + + (attr.category ? ("" + (attr.category || []).join(", ") + "
") : "") + + (attr.postcode ? ("Code postal : " + (attr.postcode || []).join(", ") + "
") : "") , + toponyme : attr.toponym, + postcode : attr.postcode[0], + postcodes : attr.postcode.join(" - "), + insee : attr.citycode[0], + citycodes : attr.citycode.join(" - "), + city : attr.city[0], + citys : attr.city.join(" - "), + category : attr.category[0], + categories : attr.category.join(" - ") + }; + + if (e.result) { + e.result.setProperties(info); + } + if (e.extent) { + e.extent.setProperties(info); + } + console.log(e); + this.dispatchEvent(e); + } else { + this.element.parentElement.parentElement.scrollTop = 0; + if (e.nbResults === 0) { + this.searchResult.innerHTML = ""; + const li = document.createElement("li"); + li.className = "fr-message--error"; + li.innerText = "Aucun résultat"; + if (!this.searchInput.value) { + li.innerText += " : précisez votre recherche en renseignant le lieu."; + } + this.searchResult.appendChild(li); + li.addEventListener("click", () => { + li.remove(); + }); + } else { + this.handleMultipleResults(e); + } + } + } + + /** + * @override + * @protected + * @param {AbstractAdvancedSearchOptions} options Options du constructeur + */ + initialize (options) { + super.initialize(options); + /** + * Nom de la classe (heritage) + * @private + */ + this.CLASSNAME = "LocationAdvancedSearch"; + + // Get type list from capabilities if not provided + fetch("https://data.geopf.fr/geocodage/getCapabilities").then(response => { + return response.json(); + }).then (json => { + // Get list from capabilities + const values = json.indexes.find(i => i.id==="poi").fields.find(f => f.name==="category").values; + this._typeList = values; + // Set categories if not provided in options + if (!options.typeList) { + this.setCategories(values); + } + }).catch(() => { + console.log("error"); + }); + this._typeList = {}; + // Default values + const typeList = [ "administratif", "aérodrome", "cimetière", "construction ponctuelle", "construction surfacique", "cours d'eau" ]; + this.set("typeList", options.typeList || typeList); + } + + /** Set categories list + * @param {Array|Object} categories Liste des catégories + * @param {HTMLSelectElement} [select] Élément select à remplir (par défaut celui du formulaire) + */ + setCategories (categories, select) { + select = select || this.element.querySelector("select[name='category']"); + select.innerHTML = ""; + let typeList = []; + const isArray = Array.isArray(categories); + if (isArray) { + typeList = categories; + } else if (typeof categories === "object") { + typeList = Object.keys(categories); + } else { + return; + } + typeList.forEach(k => { + const typeOption = document.createElement("option"); + typeOption.value = k.toLowerCase(); + typeOption.innerText = k; + select.appendChild(typeOption); + if (!isArray && Array.isArray(categories[k])) { + typeOption.className = "option"; + categories[k].forEach(v => { + const subOption = document.createElement("option"); + subOption.value = v.toLowerCase(); + subOption.className = "subOption"; + subOption.innerHTML = "  " + v; + select.appendChild(subOption); + }); + } + }); + if (this.filter) { + // Set current value + select.value = this.filter.category; + // If none selected, reset to first + if (!select.value) { + select.selectedIndex = 0; + this.filter.category = select.value; + } + } + } + + /** + * Affiche la liste des résultats multiples et permet la sélection. + * @private + * @param {Event} e Événement de recherche contenant les résultats + */ + handleMultipleResults (e) { + const results = this.searchService.getResult(); + results.forEach((result, i) => { + // console.log(result); + const attr = result.placeAttributes; + const li = document.createElement("li"); + li.className = "search-result-item" + (i>=5 ? " hidden" : ""); + this.searchResult.appendChild(li); + // link for accessibility + const a = document.createElement("a"); + li.appendChild(a); + a.className = "fr-icon-map-pin-2-line"; + a.href = "#"; + // Format title + const postCode = (attr.postcode && attr.postcode[0]) ? `, ${attr.postcode[0]}` : ""; + const text = (attr.toponym || "") + postCode + " (" + (attr.category || []).join(", ") + ") - " + (attr.city || []).join(", "); + a.title = a.innerText = text; + // on click on li or link + li.addEventListener("click", () => a.click()); + a.addEventListener("click", e => { + e.preventDefault(); + const features = this.searchService.getResultFeatures(i); + const event = { + type : "search", + multi : true, + attr : attr, + extent : features.extent, + result : features.feature, + nbResults : 1 + }; + this.searchService.dispatchEvent(event); + }); + }); + // Actions + const li = document.createElement("li"); + this.searchResult.appendChild(li); + // more options + if (results.length > 5) { + const plusBtn = document.createElement("button"); + plusBtn.className = "fr-btn fr-btn--sm fr-btn--tertiary"; + plusBtn.innerText = "Afficher plus de résultats"; + plusBtn.addEventListener("click", () => { + plusBtn.remove(); + this.searchResult.querySelectorAll(".hidden").forEach(el => { + el.classList.remove("hidden"); + }); + }); + li.appendChild(plusBtn); + } + // clear button + li.className = "search-result-actions"; + const okBtn = document.createElement("button"); + okBtn.className = "fr-btn fr-btn--sm fr-btn--tertiary"; + okBtn.innerText = "Effacer"; + okBtn.addEventListener("click", () => { + this.searchResult.innerHTML = ""; + this.element.parentElement.parentElement.scrollTop = 0; + }); + li.appendChild(okBtn); + } + + /** + * Crée un conteneur label + input pour le formulaire. + * @private + * @param {String} text Texte du label + * @param {String} type Classe CSS du conteneur + * @param {HTMLElement} input Élément input à rattacher + * @param {String} [hint] Texte d'aide optionnel + * @returns {HTMLElement} Conteneur HTML + */ + _getLabelContainer (text, type, input, hint = "") { + const container = document.createElement("div"); + container.className = type; + this.inputs.push(container); + const label = document.createElement("label"); + label.className = "fr-label"; + let labelText = text; + if (hint) { + labelText = `${text} ${hint}`; + } + label.innerHTML = labelText; + container.appendChild(label); + if (input) { + label.setAttribute("for", input.id); + container.appendChild(input); + } + // Error message + const errorDiv = document.createElement("div"); + errorDiv.className = "GPMessagesGroup fr-messages-group"; + container.appendChild(errorDiv); + + // container + return container; + } + + /** + * @override + * @protected + */ + addInputs () { + super.addInputs(); + + this.searchResult = document.createElement("ul"); + this.searchResult.className = "search-result"; + this.inputs.push(this.searchResult); + + // Legend + const legend = document.createElement("legend"); + legend.className = "fr-fieldset__legend fr-fieldset__legend--regular"; + legend.id = Helper.getUid("LocationAdvancedSearch-legend-"); + const hint = document.createElement("span"); + hint.className = "fr-hint-text"; + hint.textContent = "* Champs obligatoires"; + legend.appendChild(hint); + this.inputs.push(legend); + + // Type + const typeSelect = document.createElement("select"); + typeSelect.className = "fr-select"; + typeSelect.id = Helper.getUid("LocationAdvancedSearch-type-"); + typeSelect.name = "category"; + this._getLabelContainer("Type", "fr-select-group", typeSelect); + this.setCategories(this.get("typeList"), typeSelect); + typeSelect.addEventListener("change", () => { + this.filter.category = typeSelect.value; + }); + + // Search input + const searchInput = this.searchInput = document.createElement("input"); + searchInput.className = "fr-input"; + searchInput.type = "text"; + searchInput.name = "search"; + searchInput.setAttribute("minlength", "3"); + searchInput.required = true; + searchInput.id = Helper.getUid("LocationAdvancedSearch-search-"); + this._getLabelContainer("Renseigner un lieu *", "fr-input-group", searchInput, null); + + // Code postal + const postalInput = document.createElement("input"); + postalInput.className = "fr-input"; + postalInput.type = "text"; + postalInput.name = "postalCode"; + postalInput.pattern = "^(\\d{5})$"; + postalInput.title = "Code postal à 5 chiffres"; + postalInput.id = Helper.getUid("LocationAdvancedSearch-postal-"); + this._getLabelContainer("Code postal", "fr-input-group", postalInput); + postalInput.addEventListener("change", () => { + this.filter.postcode = postalInput.value; + }); + + // Code INSEE + const inseeInput = document.createElement("input"); + inseeInput.className = "fr-input"; + inseeInput.name = "cityCode"; + inseeInput.type = "text"; + inseeInput.pattern = "^(\\d\\d|2[A,B,a,b])\\d{3}$"; + inseeInput.title = "Code INSEE sur 5 caractères"; + inseeInput.id = Helper.getUid("LocationAdvancedSearch-insee-"); + this._getLabelContainer("Code INSEE", "fr-input-group", inseeInput, "Format attendu INSEE : 5 chiffres, selon le code officiel géographique (COG)"); + inseeInput.addEventListener("change", () => { + this.filter.citycode = inseeInput.value.toUpperCase(); + }); + + this.filter = { + category : typeSelect.value, + postcode : postalInput.value.toUpperCase(), + citycode : inseeInput.value + }; + } + + /** + * @protected + * @override + * @param {PointerEvent} e Événement d'effacement + */ + _onErase (e) { + super._onErase(e); + this.element.querySelectorAll("select").forEach(input => { + input.selectedIndex = 0; + }); + this.element.querySelectorAll("input").forEach(input => { + input.value = ""; + }); + this.searchResult.innerHTML = ""; + this._clearMessages(); + this.filter = { + category : this.element.querySelector("select[name='category']").value, + postcode : "", + citycode : "" + }; + } + + /** Clear error messages in the form + * @private + */ + _clearMessages () { + this.element.querySelectorAll(".fr-message--error").forEach(msg => msg.remove()); + } + + /** Show error message for a given input + * @param {String} name Input name + * @param {String} [message] Message to show (if none remove message) + * @private + */ + _showMessage (name, message) { + const div = this.element.querySelector("input[name='" + name + "']").nextSibling; + if (message) { + const msg = document.createElement("p"); + msg.className = "fr-message fr-message--error"; + msg.textContent = message; + div.appendChild(msg); + } else { + div.innerHTML = ""; + } + } + + /** + * @protected + * @override + * @param {PointerEvent} e Événement de soumission + * param {String} [commune] Nom de la commune (optionnel) + */ + _onSearch (e, commune) { + super._onSearch(e); + const value = commune || this.searchInput.value; + // Check values + this._clearMessages(); + /* + if (value.length < 3) { + this._showMessage("search", "Veuillez saisir au moins 3 caractères pour lancer la recherche."); + return; + } + */ + if (this.filter.postcode) { + if (!this.element.querySelector("input[name='postalCode']").checkValidity()) { + this._showMessage("postalCode", "Le code postal doit être composé de 5 chiffres."); + return; + } + } + if (this.filter.citycode) { + if (!this.element.querySelector("input[name='cityCode']").checkValidity()) { + this._showMessage("cityCode", "Le code INSEE doit être composé de 5 caractères (chiffres ou 2A, 2B)."); + return; + } + } + // Get value from code if needed + if (!value && this.filter.citycode) { + // Search commune name as search string + fetch(`https://geo.api.gouv.fr/communes?code=${this.filter.citycode}&format=json&fields=nom`).then(response => { + return response.json(); + }).then (json => { + if (json && json.length) { + const commune = json[0].nom; + if (commune) { + this._onSearch(e, commune); + } else { + this.handleSearch({ nbResults : 0 }); + } + } + }).catch(() => { + this.handleSearch({ nbResults : 0 }); + }); + return; + } else if (!value && this.filter.postcode) { + // Search commune name as search string + fetch(`https://apicarto.ign.fr/api/codes-postaux/communes/${this.filter.postcode}`).then(response => { + return response.json(); + }).then (json => { + if (json && json.length) { + const commune = json[0].nomCommune; + if (commune) { + this._onSearch(e, commune); + } else { + this.handleSearch({ nbResults : 0 }); + } + } + }).catch(() => { + this._showMessage("postalCode", "Aucune commune trouvée pour ce code postal."); + this.handleSearch({ nbResults : 0 }); + }); + return; + } else if (value.length < 3) { + this._showMessage("search", "Veuillez saisir au moins 3 caractères pour lancer la recherche."); + return; + } + + // Search + this.searchService.search({ + location : value, + filters : this.filter + }); + } + +} + +export default LocationAdvancedSearch; + +// Expose LocationAdvancedSearch as ol.control.LocationAdvancedSearch (for a build bundle) +if (window.ol && window.ol.control) { + window.ol.control.LocationAdvancedSearch = LocationAdvancedSearch; +} diff --git a/src/packages/Controls/SearchEngine/ParcelAdvancedSearch.js b/src/packages/Controls/SearchEngine/ParcelAdvancedSearch.js new file mode 100644 index 000000000..28adc14aa --- /dev/null +++ b/src/packages/Controls/SearchEngine/ParcelAdvancedSearch.js @@ -0,0 +1,867 @@ +import def from "ajv/dist/vocabularies/discriminator"; +import InseeSearchService from "../../Services/InseeSearchService"; +import Helper from "../../Utils/Helper"; +import AbstractAdvancedSearch from "./AbstractAdvancedSearch"; +import IGNSearchService from "../../Services/IGNSearchService"; + +/** + * @classdesc + * Contrôle de recherche parcellaire. + * + * @extends {AbstractAdvancedSearch} + * @alias ol.control.ParcelAdvancedSearch + * @module ParcelAdvancedSearch + */ +class ParcelAdvancedSearch extends AbstractAdvancedSearch { + + /** + * Constructeur du contrôle de recherche avancée. + * @param {AbstractAdvancedSearchOptions} options Options du constructeur + * @param {String} [options.name="Parcelles"] Nom du contrôle + * @extends {ol.control.AbstractAdvancedSearch} + */ + constructor (options) { + options = options || {}; + + options.name = options.name || "Parcelles cadastrales"; + + // call ol.control.Control constructor + super(options); + + // prevent HTML5 validation + this.element.setAttribute("novalidate", ""); + + this.searchService = new IGNSearchService({ + index : "parcel", + limit : 1, + returnTrueGeometry : true + }); + this.searchService.on("search", e => this.handleSearch(e)); + this.searchService.on("error", e => this.handleError(e)); + } + + /** An error occured during search + * @param {Object} e Event + */ + handleError (e) { + console.log("error", e); + this.dispatchEvent(e); + } + + /** Search completed + * @param {Object} e Event + */ + handleSearch (e) { + if (e.nbResults === 0) { + this._showMessage("numero", "Aucune parcelle ne correspond à ce numéro."); + return; + } + // Format result + const attr = e.attr || this.searchService.getResult(0).placeAttributes; + delete attr.truegeometry; + attr.infoPopup = `${attr.id}
+ Parcelle n° ${attr.number.replace(/^0{1,4}/g,"")}
+ Feuille ${attr.oldmunicipalitycode}-${attr.section}
+ ${attr.city} + `; + // Set properties + e.result?.setProperties(attr); + e.extent?.setProperties(attr); + + this.dispatchEvent(e); + } + + + /** Clear error messages in the form + * @private + */ + _clearMessages () { + this.element.querySelectorAll(".fr-message--error").forEach(msg => msg.remove()); + } + + /** Show error message for a given input + * @param {String} name Input name + * @param {String} [message] Message to show (if none remove message) + * @param {String} [what="error"] Message type (error, warning, info) + * @private + */ + _showMessage (name, message, what) { + const div = this.element.querySelector("[name='" + name + "']").parentElement.querySelector(".GPMessagesGroup"); + if (message) { + const msg = document.createElement("p"); + msg.className = "fr-message fr-message--" + (what || "error"); + msg.textContent = message; + div.innerHTML = ""; + div.appendChild(msg); + } else { + div.innerHTML = ""; + } + } + + /** Change the section + * @private + */ + setSection () { + const prefix = this.prefixInput.value; + const section = this.sectionInput.value; + this.numberList.innerHTML = ""; + this._showMessage("section", "chargement en cours", "info"); + this._fetchCadastre(this.communeId, this.arrondId, prefix, section).then(data => { + this._showMessage("section", ""); + const section = this.sectionInput.value; + if (data && data.features && data.features[0].properties.section === section) { + this.numberInput.focus(); + const numbers = []; + data.features.forEach(numero => numbers.push(numero.properties.numero)); + // Sort numbers + numbers.sort().forEach(numero => { + const option = document.createElement("li"); + option.value = option.textContent = numero.replace(/^0{1,4}/g,""); + option.id = Helper.getUid("GPautoCompleteOption-"); + option.addEventListener("click", () => { + this.numberInput.value = option.value; + this._onSearch(); + this.numberInput.blur(); + this.numberInput.ariaExpanded = "false"; + this.numberInput.setAttribute("aria-activedescendant", ""); + }); + this.numberList.appendChild(option); + }); + this.filterListNumber(); + } + }); + } + + /** + * Show sections for a given prefix + * @param {String} prefix Prefix code + */ + setFeuille (prefix) { + const feuilles = this.feuilles[prefix]; + this.sectionInput.innerHTML = ""; + if (feuilles) { + const section = document.createElement("option"); + section.value = ""; + section.textContent = "Sélectionner une section"; + this.sectionInput.appendChild(section); + let previous = ""; + this.feuilles[prefix].sort().forEach(key => { + if (key !== previous) { + const section = document.createElement("option"); + section.value = section.textContent = key; + this.sectionInput.appendChild(section); + previous = key; + } + }); + this.sectionInput.removeAttribute("disabled"); + this.sectionInput.focus(); + } else { + this.sectionInput.setAttribute("disabled", "disabled"); + } + this.sectionInput.dispatchEvent(new Event("change")); + } + + /** Set the commune + * @param {String} [id] Commune INSEE code + * @param {String} [arrond] Arrondissement code + */ + setCommune (id="", arrond="") { + if (this.communeId !== id) { + const prefixInput = this.prefixInput; + + this.communeId = id; + this.arrondId = arrond; + prefixInput.innerHTML = ""; + if (id) { + this._showMessage("commCode", "chargement en cours", "info"); + // Fetch prefixes and sections for the selected commune + this._fetchCadastre(id, arrond).then(data => { + this._showMessage("commCode", ""); + this.feuilles = {}; + + data.features.forEach(feuille => { + if (!this.feuilles[feuille.properties.com_abs]) { + this.feuilles[feuille.properties.com_abs] = []; + } + this.feuilles[feuille.properties.com_abs].push(feuille.properties.section); + }); + let previous = ""; + Object.keys(this.feuilles).sort().forEach(key => { + if (key !== previous) { + const prefix = document.createElement("option"); + prefix.value = prefix.textContent = key; + prefixInput.appendChild(prefix); + previous = key; + } + }); + this.setFeuille(prefixInput.value); + }); + prefixInput.removeAttribute("disabled"); + } else { + prefixInput.setAttribute("disabled", "disabled"); + this.setFeuille(); + } + } + }; + + /** + * @override + * @protected + * @param {AbstractAdvancedSearchOptions} options Options du constructeur + */ + _initEvents (options) { + super._initEvents(options); + + // Focus commune input on expand + this.on("expand", e => { + if (e.expanded) { + setTimeout(() => comCodeInput.focus(), 300); + } + }); + + // Inputs + const comCodeInput = this.comCodeInput; + const autocompleteList = this.autocompleteList; + const prefixInput = this.prefixInput; + const sectionInput = this.sectionInput; + + // Current selected commune + let communeName = ""; + this.communeId = ""; + // Autocomplete selected index + let selectedIndex = -1; + let parcelIndex = -1; + + // Show/hide autocomplete list + const showAutocomplete = (b) => { + if (communeName !== comCodeInput.value) { + this.setCommune(); + } + if (b !== false) { + // Show autocomplete only if input value matches selected commune + b = (communeName === comCodeInput.value); + } + if (b) { + comCodeInput.setAttribute("aria-expanded", "true"); + comCodeInput.setAttribute("aria-activedescendant", autocompleteList.children[selectedIndex]?.id || ""); + } else { + comCodeInput.setAttribute("aria-expanded", "false"); + autocompleteList.children[selectedIndex]?.classList.remove("active"); + comCodeInput.setAttribute("aria-activedescendant", autocompleteList.children[selectedIndex]?.id || ""); + selectedIndex = -1; + } + }; + + + // Keyboard navigation on autocomplete list + let previousValue = ""; + comCodeInput.addEventListener("keydown", e => { + // Prevent default behavior for navigation keys if elements are disabled + if (e.key === "Tab") { + if (!e.shiftKey // backward tab + && comCodeInput.value // input not empty + && previousValue !== comCodeInput.value // value not changed + && prefixInput.getAttribute("disabled") === "disabled" // prefix disabled + ) { + e.preventDefault(); + comCodeInput.dispatchEvent(new Event("change")); + } + } + previousValue = comCodeInput.value; + // Autocomplete navigation + const autoOptions = autocompleteList.children; + if (autoOptions.length) { + let index = selectedIndex; + switch (e.key) { + case "Backspace": { + index = -1; + if (comCodeInput.value.length > 5) { + comCodeInput.value = comCodeInput.value.slice(0,6); + } + break; + } + case "Escape": { + index = -1; + break; + } + case "ArrowDown": { + index++; + if (index >= autoOptions.length) { + index = -1; + } + e.preventDefault(); + break; + } + case "ArrowUp": { + index--; + if (index < -1) { + index += autoOptions.length +1; + } + e.preventDefault(); + break; + } + case "Enter": { + if (index >= 0 && index < autoOptions.length) { + autoOptions[selectedIndex]?.classList.remove("active"); + autoOptions[index].click(); + index = -1; + } + break; + } + default: { + break; + } + } + if (selectedIndex !== index) { + // Update selected option + autoOptions[selectedIndex]?.classList.remove("active"); + selectedIndex = index; + autoOptions[selectedIndex]?.classList.add("active"); + showAutocomplete(["ArrowUp", "ArrowDown"].includes(e.key)); + // Scroll to selected option + autoOptions[selectedIndex]?.scrollIntoView({ block : "nearest" }); + } + } + }); + + // Show autocomplete on input + comCodeInput.addEventListener("keyup", e => { + if (["ArrowUp", "ArrowDown", "Enter", "Escape"].includes(e.key)) { + e.preventDefault(); + return; + } + showAutocomplete(e); + }); + comCodeInput.addEventListener("focus", showAutocomplete); + comCodeInput.addEventListener("blur", (e) => { + if (e.relatedTarget !== autocompleteList) { + showAutocomplete(false); + } + }); + + // Fomat commune name for display / postal codes + function formatCommune (commune, cpost) { + const name = `${commune.arrond || commune.code} ${commune.nom}`; + if (cpost && commune.codesPostaux) { + return `${name}, ${commune.codesPostaux.join(", ")} (postal)`; + } + return name; + } + + // Fetch commune data on change + comCodeInput.addEventListener("change", () => { + //check valid input + if (!comCodeInput.checkValidity()) { + this._showMessage("commCode", "Le code INSEE doit être composé de 5 caractères (chiffres ou 2A, 2B) ou le code postal de 5 chiffres."); + return; + } else { + this._clearMessages(); + } + this._showMessage("commCode", "chargement en cours", "info"); + this._fetchCommuneData(comCodeInput.value).then(data => { + this._showMessage("commCode", ""); + // Clear previous suggestions + autocompleteList.innerHTML = ""; + communeName = ""; + if (data.length === 0) { + // errror message + this._showMessage("commCode", "Aucune commune ne correspond à ce code INSEE ou code postal."); + this.setCommune(); + } else if (data.length === 1) { + communeName = comCodeInput.value = formatCommune(data[0]); + this.setCommune(data[0].code, data[0].arrond); + } else { + data.forEach(commune => { + const type = commune.codesPostaux ? "code INSEE" : "code postal"; + const option = document.createElement("li"); + option.className = "GPautoCompleteOption"; + option.setAttribute("role", "option"); + option.id = Helper.getUid("GPautoCompleteOption-"); + option.title = option.textContent = formatCommune(commune, type); + option.addEventListener("click", () => { + communeName = comCodeInput.value = formatCommune(commune); + this.setCommune(commune.code, commune.arrond); + showAutocomplete(false); + }); + autocompleteList.appendChild(option); + }); + communeName = comCodeInput.value; + this.setCommune(); + comCodeInput.setAttribute("aria-expanded", "true"); + } + }); + }); + + // Fetch sections + prefixInput.addEventListener("change", () => { + this.setFeuille(prefixInput.value); + }); + + // Fetch parcelles number + let selectTout = null; + let hascliked = false; + // Handle click and change separately to manage click on the list + sectionInput.addEventListener("click", () => { + hascliked = true; + }); + sectionInput.addEventListener("change", e => { + // Wait for click event to be handled first + setTimeout(() => { + clearTimeout(selectTout); + // If clicked, do it immediately, else wait a bit (use key to navigate the list) + selectTout = setTimeout(() => { + if (sectionInput.value) { + this.numberInput.removeAttribute("disabled"); + this.setSection(); + } else { + this.numberInput.setAttribute("disabled", "disabled"); + } + }, hascliked ? 0 : 500); + }); + hascliked = false; + }); + + // Handle listbox for number input + this.numberInput.addEventListener("focus", () => { + this.numberInput.ariaExpanded = "true"; + }); + this.numberInput.addEventListener("blur", (e) => { + if (e.relatedTarget !== this.numberList) { + this.numberInput.ariaExpanded = "false"; + } + }); + + // Filter number list on keyup + this.numberInput.addEventListener("keydown", (e) => { + if (["ArrowUp", "ArrowDown", "Enter", "Escape"].includes(e.key)) { + e.preventDefault(); + return; + } + }); + this.numberInput.addEventListener("keyup", (e) => { + this.filterListNumber(); + let index = parcelIndex; + switch (e.key) { + case "ArrowDown": { + // Next non hidden option + for (let i=parcelIndex +1; i < this.numberList.children.length; i++) { + if (this.numberList.children[i].style.display !== "none") { + index = i; + break; + } + } + if (index >= this.numberList.children.length) { + index = -1; + } + e.preventDefault(); + break; + } + case "ArrowUp": { + // Previous non hidden option + for (let i=parcelIndex -1; i >= -1; i--) { + if (i === -1 || this.numberList.children[i].style.display !== "none") { + index = i; + break; + } + } + if (index < -1) { + index += this.numberList.children.length +1; + } + e.preventDefault(); + break; + } + case "Escape": { + index = -1; + this.numberInput.ariaExpanded = "false"; + break; + } + case "Enter": { + //this.numberList.children[index]?.click(); + const value = this.numberList.children[index]?.value || ""; + if (value) { + this.numberInput.value = value; + this._onSearch(); + this.filterListNumber(); + index = -1; + setTimeout(() => this.numberInput.ariaExpanded = "false"); + } + break; + } + default: { + // reset selection + index = -1; + break; + } + } + if (parcelIndex !== index) { + // Update selected option + this.numberList.children[parcelIndex]?.classList.remove("active"); + parcelIndex = index; + this.numberList.children[parcelIndex]?.classList.add("active"); + this.numberInput.setAttribute("aria-activedescendant", this.numberList.children[parcelIndex]?.id || ""); + // Scroll to selected option + this.numberInput.ariaExpanded = "true"; + this.numberInput.setAttribute("aria-activedescendant", this.numberList.children[parcelIndex]?.id || ""); + this.numberList.children[parcelIndex]?.scrollIntoView({ block : "nearest" }); + } + }); + } + + + /** Filter listbox options on input value + */ + filterListNumber () { + const filter = this.numberInput.value.toUpperCase(); + if (this.currentFilter === filter) { + return; + } + this.currentFilter = filter; + const options = this.numberList.querySelectorAll("li"); + let hasNumber = false; + options.forEach(option => { + if (option.textContent.toUpperCase().indexOf(filter) > -1) { + option.style.display = ""; + hasNumber = true; + } else { + option.style.display = "none"; + } + }); + if (!hasNumber) { + this._showMessage("numero", "Aucun numéro de parcelle ne correspond à cette saisie."); + } else { + this._showMessage("numero", ""); + this.numberInput.ariaExpanded = "true"; + } + } + + /** + * @override + * @protected + * @param {AbstractAdvancedSearchOptions} options Options du constructeur + */ + initialize (options) { + super.initialize(options); + /** + * Nom de la classe (heritage) + * @private + */ + this.CLASSNAME = "ParcelAdvancedSearch"; + + this.insseSearchService = new InseeSearchService(); + } + + /** + * Crée un conteneur label + input pour le formulaire. + * @private + * @param {String} text Texte du label + * @param {String} type Classe CSS du conteneur + * @param {HTMLElement} input Élément input à rattacher + * @param {String} [hint] Texte d'aide optionnel + * @returns {HTMLElement} Conteneur HTML + */ + _getLabelContainer (text, type, input, hint = "") { + const container = document.createElement("div"); + container.className = type; + this.inputs.push(container); + const label = document.createElement("label"); + label.className = "fr-label"; + let labelText = text; + if (hint) { + labelText = `${text} ${hint}`; + } + label.innerHTML = labelText; + container.appendChild(label); + if (input) { + label.setAttribute("for", input.id); + container.appendChild(input); + } + // Error message + const errorDiv = document.createElement("div"); + errorDiv.className = "GPMessagesGroup fr-messages-group"; + container.appendChild(errorDiv); + + // container + return container; + } + + /** + * Récupère les données d'une commune via l'API geo.api.gouv.fr + * @private + * @param {String} code Code INSEE ou code postal + * @returns {Promise} Promesse avec les données de la commune : nom + code + codesPostaux (si codepostal)) + */ + async _fetchCommuneData (code) { + const baseURL = "https://geo.api.gouv.fr/communes"; + const url1 = `${baseURL}?code=${code}&format=json&fields=nom,code`; + const url2 = `${baseURL}?codePostal=${code}&format=json&fields=nom,codesPostaux`; + const param ={ + headers : { + "Content-Type" : "application/json", + }, + }; + // Fetch both insee code and postal code + const fetchtable = [ + fetch(url1, param), + fetch(url2, param), + ]; + // For municipal arrondissements, fetch them too + if (/^75|^6900|^6938|^1300|^1301|^1320|^1321/.test(code)) { + fetchtable.push( + fetch(url1 + "&type=arrondissement-municipal", param), + ); + fetchtable.push( + fetch(url2 + "&type=arrondissement-municipal", param), + ); + } + // Process responses + return Promise.all(fetchtable).then(responses => { + return Promise.all(responses.map(res => res.json())).then(json => { + // Handle insee code + if (json[2] && json[2].length) { + json[2].forEach(com => { + com.arrond = com.code; + switch (com.code.slice(0,2)) { + // Paris + case "75": { + com.code = "75056"; + break; + } + // Lyon + case "69": { + com.code = "69123"; + break; + } + // Marseille + case "13": { + com.code = "13055"; + break; + } + default: { + break; + } + } + }); + } + // Handle arrondissements municipaux + if (json[3] && json[3].length) { + const arrond = json[3][0]; + const cpost = arrond.codesPostaux[0]; + if (json[1].length) { + // Filter out duplicates with same postal code + const filter = json[1].filter(com => com.codesPostaux.includes(cpost) >= 0); + json[1] = json[1].filter(com => com.codesPostaux.includes(cpost) < 0); + const com = filter.find(com => com.code !== arrond.code); + if (com) { + arrond.arrond = arrond.code; + arrond.code = com.code; + } + } + } + return json[0].concat(json[1]).concat(json[2]||[]).concat(json[3]||[]); + }); + }).catch(error => { + this._showMessage("commCode", "Une erreur est survenue lors de la récupération des données de la commune."); + return []; + }); + } + + /** + * Récupère les feuilles cadastrales d'une commune via le WFS Geopf + * @private + * @param {String} code Code INSEE de la commune + * @param {String} [arrond] Code de l'arrondissement (pour les communes avec arrondissements municipaux) + * @param {String} [prefix] Préfixe de la parcelle + * @param {String} [section] Section de la parcelle + * @returns {Promise} Promesse avec les données GeoJSON (feuilles ou parcelles si section renseignée) + */ + async _fetchCadastre (code, arrond, prefix, section) { + const domtom = ["97","98"].includes(code.slice(0,2)); + const dep = code.slice(0, domtom ? 3 : 2); + const com = code.slice(domtom ? 3 : 2, 5); + const url = "https://data.geopf.fr/wfs/ows?"; + const params = { + service : "WFS", + version : "2.0.0", + request : "GetFeature", + typename : "CADASTRALPARCELS.PARCELLAIRE_EXPRESS:" + (section ? "parcelle" : "feuille"), + outputFormat : "application/json", + srsName : "CRS:84", + count : "1000", + propertyName : section ? "com_abs,section,numero" : "com_abs,section,code_arr", + cql_filter : `code_dep='${dep}' and code_com='${com}'` + (arrond ? `and code_arr='${arrond.slice(2)}'` : "") + (section ? ` and com_abs='${prefix}' and section='${section}'` : "") + }; + const queryString = new URLSearchParams(params).toString(); + const fullUrl = url + queryString; + // Abort previous request + if (this.controller) { + this.controller.abort(); + } + // New request + this.controller = new AbortController(); + const response = await fetch(fullUrl, { + headers : { + "Content-Type" : "application/json", + }, + signal : this.controller.signal + }).catch(error => { + return null; + }); + this.controller = null; + if (!response) { + return {}; + } + const data = await response.json(); + return data; + } + + /** Add inputs on form + * @override + * @protected + */ + addInputs () { + super.addInputs(); + + // Legend + const legend = document.createElement("legend"); + legend.className = "fr-fieldset__legend fr-fieldset__legend--regular"; + legend.id = Helper.getUid("ParcelAdvancedSearch-legend-"); + const hint = document.createElement("span"); + hint.className = "fr-hint-text"; + hint.textContent = "* Champs obligatoires"; + legend.appendChild(hint); + this.inputs.push(legend); + + // Code postal + const comCodeInput = this.comCodeInput = document.createElement("input"); + comCodeInput.className = "fr-input"; + comCodeInput.type = "text"; + comCodeInput.name = "commCode"; + comCodeInput.pattern = "^(\\d\\d|2[A,B,a,b,0-9])\\d{3}$"; + comCodeInput.title = "Code postal ou code INSEE"; + comCodeInput.id = Helper.getUid("ParcelAdvancedSearch-comCode-"); + const codeDiv = this._getLabelContainer("Renseigner un lieu*", "fr-input-group", comCodeInput, "Code postal ou code INSEE"); + + // Autocomplete list + const autocompleteList = this.autocompleteList = document.createElement("ul"); + autocompleteList.className = "GPautoCompleteList"; + autocompleteList.id = Helper.getUid("GPautoCompleteList-"); + autocompleteList.setAttribute("role", "listbox"); + autocompleteList.setAttribute("tabindex", "-1"); + autocompleteList.setAttribute("aria-label", "Propositions"); + codeDiv.insertBefore(autocompleteList, comCodeInput.nextSibling); + + // Input controller for accessibility + comCodeInput.setAttribute("role", "combobox"); + comCodeInput.setAttribute("aria-controls", autocompleteList.id); + comCodeInput.setAttribute("aria-expanded", "false"); + comCodeInput.setAttribute("aria-autocomplete", "list"); + comCodeInput.setAttribute("aria-haspopup", "listbox"); + comCodeInput.setAttribute("autocomplete", "off"); + + // Prefix + const prefixInput = this.prefixInput = document.createElement("select"); + prefixInput.className = "fr-select"; + prefixInput.name = "prefix"; + prefixInput.title = "Choisir le préfixe de la parcelle"; + prefixInput.id = Helper.getUid("ParcelAdvancedSearch-prefix-"); + prefixInput.setAttribute("disabled", "disabled"); + this._getLabelContainer("Préfix", "fr-select-group", prefixInput, ""); + + // Section + const sectionInput = this.sectionInput = document.createElement("select"); + sectionInput.className = "fr-select"; + sectionInput.name = "section"; + sectionInput.title = "Choisir la section de la parcelle"; + sectionInput.id = Helper.getUid("ParcelAdvancedSearch-section-"); + sectionInput.setAttribute("disabled", "disabled"); + this._getLabelContainer("Section*", "fr-select-group", sectionInput, ""); + + // Numero + const numberInput = this.numberInput = document.createElement("input"); + numberInput.className = "fr-input"; + numberInput.type = "text"; + numberInput.pattern = "\\d{1,4}"; + numberInput.maxLength = "4"; + numberInput.name = "numero"; + numberInput.title = "Numéro de la parcelle"; + numberInput.autocomplete = "off"; + numberInput.ariaExpanded = "false"; + numberInput.id = Helper.getUid("ParcelAdvancedSearch-number-"); + numberInput.setAttribute("disabled", "disabled"); + const numDiv = this._getLabelContainer("Numéro*", "fr-input-group", numberInput, ""); + + const numberList = this.numberList = document.createElement("ul"); + numberList.className = "GPautoCompleteList"; + numberList.id = Helper.getUid("GPautoCompleteList-"); + numberList.setAttribute("role", "listbox"); + numberList.setAttribute("tabindex", "-1"); + numberList.setAttribute("aria-label", "Propositions"); + numDiv.insertBefore(numberList, numberInput.nextSibling); + } + + /** Do search + * @protected + * @override + * @param {PointerEvent} e Événement de soumission + */ + _onSearch (e) { + if (e) { + super._onSearch(e); + } + // Nothing to do if no commune selected + if (!this.communeId) { + return; + } + + // Check form validity + this._clearMessages(); + + const prefix = this.prefixInput.value; + const section = this.sectionInput.value; + const number = this.numberInput.value; + if (!prefix) { + // this._showMessage("prefix", "Le préfixe est obligatoire."); + return; + } else if (!section) { + this._showMessage("section", "La section est obligatoire."); + return; + } else if (!this.numberInput.checkValidity()) { + this._showMessage("numero", "Le numéro de parcelle doit être un nombre."); + return; + } else if (!number) { + this._showMessage("numero", "Le numéro de parcelle est obligatoire."); + return; + } + + // Gestion des arrondissements municipaux + const communeId = this.arrondId || this.communeId; + // Search parcelle + const parcelId = communeId + + "000".slice(prefix.length) + prefix + + section + + "0000".slice(number.length) + number; + + this.searchService.search({ + location : parcelId + }); + } + + /** + * Réinitialise les champs du formulaire. + * @param {PointerEvent} e Événement d'effacement + * @protected + */ + _onErase (e) { + super._onErase(e); + this.setCommune(); + this._clearMessages(); + } + +} + +export default ParcelAdvancedSearch; + +// Expose ParcelAdvancedSearch as ol.control.ParcelAdvancedSearch (for a build bundle) +if (window.ol && window.ol.control) { + window.ol.control.ParcelAdvancedSearch = ParcelAdvancedSearch; +} \ No newline at end of file diff --git a/src/packages/Controls/SearchEngine/SearchEngineAdvanced.js b/src/packages/Controls/SearchEngine/SearchEngineAdvanced.js new file mode 100644 index 000000000..571612a8c --- /dev/null +++ b/src/packages/Controls/SearchEngine/SearchEngineAdvanced.js @@ -0,0 +1,685 @@ +import Control from "../Control"; +import Geolocation from "ol/Geolocation"; +import OlFeature from "ol/Feature"; +import Point from "ol/geom/Point"; +import SearchEngineGeocodeIGN from "./SearchEngineGeocodeIGN"; +import Helper from "../../Utils/Helper"; +import Select, { SelectEvent } from "ol/interaction/Select"; +import Map from "ol/Map"; + +import Vector from "ol/layer/Vector"; +import VectorSource from "ol/source/Vector"; + +import Overlay from "ol/Overlay.js"; + +import { Style, Icon, Stroke, Fill } from "ol/style"; +import mapPinIcon from "./map-pin-2-fill.svg"; +import Feature from "ol/Feature"; +import { Layer } from "ol/layer"; +import AbstractAdvancedSearch from "./AbstractAdvancedSearch"; + +/** Get style for features + * @param {String|Array} color - Couleur du contour + * @param {String|Array} [fillColor] - Couleur de remplissage + * @param {Number} [offset = 0] - Décalage de la ligne. Par défaut, 0 + * @returns {Style} Style OpenLayers + */ +function getStyle (color, fillColor, offset = 0) { + return new Style({ + image : new Icon({ + src : mapPinIcon, + color : color, + anchor : [0.5, 1], + }), + stroke : new Stroke({ + color : color, + lineDash : [8, 8], + width : 2, + lineDashOffset : offset + }), + fill : new Fill({ + color : fillColor || "rgba(0, 0, 0, 0.1)", + }), + }); +} + +/** + * @classdesc + * Contrôle de recherche avancée permettant de rechercher via d'autres manières. + * Gère aussi l'ajout des élements sur la carte etc. + * + * @extends {Control} + * @module SearchEngineAdvanced + */ +class SearchEngineAdvanced extends Control { + + /** + * Constructeur du contrôle de recherche avancée. + * @param {SearchEngineAdvancedOptions} options - Options du constructeur. + */ + constructor (options) { + options = options || {}; + // call ol.control.Control constructor + + super(options); + + // Geolocation + this.geolocation = new Geolocation({ + // enableHighAccuracy must be set to true to have the heading value. + trackingOptions : { + enableHighAccuracy : true, + }, + projection : "EPSG:4326", + }); + + + this.layer = new Vector({ + source : new VectorSource({}), + zIndex : Infinity, + style : [getStyle([255, 255, 255, 1]), getStyle([0, 0, 145, 1], null, 8)], + }); + + this.selectInteraction = new Select({ + layers : [this.layer], + style : getStyle([145, 0, 0, 1], [145, 0, 0, 0.2]), + }); + + // Initialize + this.initialize(options); + this._initContainer(options); + this._initEvents(options); + + options.popupButtons = options.popupButtons ? options.popupButtons : []; + + this.selectInteraction.on("select", this._onSelectElement.bind(this)); + this.popup = this._createPopup(options.popupButtons); + } + + /** + * Initialise les options du contrôle. + * @param {SearchEngineAdvancedOptions} options - Options du constructeur. + * @private + */ + initialize (options) { + /** + * Nom de la classe (heritage) + * @private + */ + this.CLASSNAME = "SearchEngineAdvanced"; + + /** + * @type {Array} + */ + this._searchForms; + + /** + * Si vrai, écoute les clics sur le document pour gérer + * la modale de recherche avancée + * @type {Boolean} + */ + this.listenToClick = false; + + if (options.advancedSearch && options.advancedSearch instanceof Array) { + this._searchForms = options.advancedSearch; + } else { + this._searchForms = []; + } + + this._searchForms.forEach(search => { + // Gère la recherche + search.on("search", this.onAdvancedSearchResult.bind(this)); + }); + } + + /** + * @override + * @param {Map|null} map Carte cible + */ + setMap (map) { + if (this.getMap() && this.baseSearchEngine) { + this.getMap().removeControl(this.baseSearchEngine); + } + super.setMap(map); + if (this.baseSearchEngine) { + this.baseSearchEngine.setMap(map); + } + this._searchForms.forEach(search => { + search.setMap(map); + }); + + this.element.appendChild(this.advancedContainer); + + if (map) { + // Place les couches au dessus des autres + this.layer.setMap(map); + map.addInteraction(this.selectInteraction); + map.addOverlay(this.popup); + } + } + + /** + * Retourne la couche utilisée pour afficher les résultats. + * @returns {Layer} Couche des résultats + */ + getLayer () { + return this.layer; + } + + /** + * Initialise les événements du contrôle (géolocalisation, navigation clavier, recherche). + * @param {SearchEngineAdvancedOptions} options Options du constructeur. + * @private + */ + _initEvents (options) { + this.geolocation.on("change:position", () => { + const pt = new Point(this.geolocation.getPosition()); + pt.transform("EPSG:4326", this.getMap().getView().getProjection()); + const evt = this.createEvent(pt, "Ma localisation"); + this.addResultToMap(evt); + this.dispatchEvent(evt); + this.geolocation.setTracking(false); + }); + + this.baseSearchEngine.input.addEventListener("keydown", function (/** @type {KeyboardEvent} */ e) { + if (e.key === "Tab" && !e.shiftKey && this.locationBtn.checkVisibility()) { + e.preventDefault(); + this.locationBtn.focus(); + } + }.bind(this)); + + this.locationBtn.addEventListener("keydown", function (/** @type {KeyboardEvent} */ e) { + if (e.key === "Tab") { + e.preventDefault(); + if (e.shiftKey) { + // Retourne sur l'input + this.baseSearchEngine.input.focus(); + } else if (this.advancedBtn.checkVisibility()) { + // Focus sur le bouton de recherche avancée + this.advancedBtn.focus(); + } else { + this.eraseBtn.focus(); + } + } + }.bind(this)); + + this.on("search", this.addResultToMap.bind(this)); + + // Gère le cas du conteneur de recherche avancée + ["mousedown", "focusin"].map(eventListener => document.addEventListener(eventListener, this._onDocumentClick.bind(this))); + + this.advancedBtn.addEventListener("blur", function (e) { + if (e.relatedTarget === this.baseSearchEngine.input) { + this.listenToClick = false; + this.advancedBtn.setAttribute("aria-expanded", false); + } + }.bind(this)); + + this.baseSearchEngine.input.addEventListener("blur", function (/** @type {FocusEvent} */e) { + if (e.relatedTarget && e.relatedTarget === this.eraseBtn) { + e.target.dispatchEvent(new Event("input")); + } + }.bind(this)); + } + + /** + * Crée un événement de recherche à partir d'un objet (Feature ou Point). + * @param {Object|Point|OlFeature} obj Objet à afficher (Feature ou Point) + * @param {String} [info] Texte affiché dans la popup + * @returns {Object} Événement normalisé de type "search" + */ + createEvent (obj, info) { + let evt = obj; + if (obj instanceof OlFeature) { + evt = { + result : obj, + extent : null + }; + } else if (obj instanceof Point) { + evt = { + result : new OlFeature(obj), + extent : null + }; + } + if (info) { + evt.result.set("infoPopup", info); + } + evt.type = "search"; + return evt; + } + + /** + * Initialise le conteneur principal du contrôle et les sous-composants. + * @param {SearchEngineAdvancedOptions} options Options du constructeur + * @private + */ + _initContainer (options) { + // Gestion de l'affichage des options avancées + const element = this.element = document.createElement("div"); + element.className = "GPwidget gpf-widget"; + element.id = Helper.getUid("GPsearchEngine-Advanced-"); + + // Default base search engine + // const baseContainer = this.baseContainer = document.createElement("div"); + // this.element.appendChild(baseContainer); + options.target = this.element; + options.searchButton = true; + options.search = true; + this.baseSearchEngine = new SearchEngineGeocodeIGN(options); + this.baseSearchEngine.on(["select", "search", "autocomplete"], (e) => { + this.dispatchEvent(e); + }); + + // Geolocation + this.locationBtn = this._getGeolocButton(); + this.baseSearchEngine.autocompleteHeader.appendChild(this.locationBtn); + + // Ajout des options avancées + const advancedBtn = this.advancedBtn = document.createElement("button"); + advancedBtn.className = "GPSearchEngine-advanced-btn fr-btn fr-btn--sm fr-icon-arrow-up-s-line fr-btn--icon-right fr-btn--tertiary-no-outline"; + advancedBtn.id = Helper.getUid("GPSearchEngine-advanced-btn-"); + advancedBtn.type = "button"; + advancedBtn.title = "Avancée"; + advancedBtn.innerHTML = "Avancée"; + advancedBtn.setAttribute("aria-label", "Afficher les options avancées"); + advancedBtn.setAttribute("aria-expanded", "false"); + + // Gestion de l'affichage des options avancées + const advancedContainer = this.advancedContainer = document.createElement("div"); + advancedContainer.className = "GPAdvancedContainer"; + advancedContainer.id = Helper.getUid("GPsearchEngine-AdvancedContainer-"); + advancedContainer.setAttribute("aria-labelledby", advancedBtn.id); + // baseContainer.appendChild(advancedContainer); + + // Geolocation + advancedContainer.appendChild(this._getGeolocButton()); + + // Formulaires specifiques + this._searchForms.forEach(Search => { + const section = document.createElement("section"); + section.className = "fr-accordion"; + advancedContainer.appendChild(section); + const title = document.createElement("h3"); + title.className = "fr-accordion__title"; + section.appendChild(title); + const button = document.createElement("button"); + button.type = "button"; + button.className = "fr-accordion__btn"; + button.setAttribute("aria-expanded", "false"); + button.innerText = Search.getName(); + title.appendChild(button); + // Accordion + const accordion = document.createElement("div"); + accordion.className = "fr-collapse"; + accordion.id = Helper.getUid("accordion-"); + button.setAttribute("aria-controls", accordion.id); + section.appendChild(accordion); + + // Contenu recherche avancée + Search.setTarget(accordion); + + button.addEventListener("click", () => { + const expanded = button.getAttribute("aria-expanded") === "true"; + advancedContainer.querySelectorAll("section").forEach(sec => { + sec.querySelector(".fr-collapse").classList.remove("fr-collapse--expanded"); + sec.querySelector("button").setAttribute("aria-expanded", "false"); + advancedContainer.dataset.open = !expanded; + if (!expanded) { + sec.classList.add("fr-hidden"); + } else { + sec.classList.remove("fr-hidden"); + } + }); + if (!expanded) { + button.setAttribute("aria-expanded", "true"); + accordion.classList.add("fr-collapse--expanded"); + section.classList.remove("fr-hidden"); + } + Search.dispatchEvent({ type : "expand", expanded : !expanded }); + }); + }); + + // Gestion du bouton avancé + advancedBtn.setAttribute("aria-controls", advancedContainer.id); + advancedBtn.addEventListener("click", function (/** @type {PointerEvent} */ e) { + e.preventDefault(); + const isHidden = advancedBtn.getAttribute("aria-expanded") === "false"; + advancedBtn.setAttribute("aria-expanded", isHidden); + this.listenToClick = isHidden; + if (isHidden) { + // Si la modale est ouverte, on met le focus sur le premier élément focusable + const focusableSelectors = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + "[tabindex]:not([tabindex='-1'])" + ].join(","); + const firstFocusable = advancedContainer.querySelector(focusableSelectors); + if (firstFocusable) { + firstFocusable.focus(); + } + } + }.bind(this)); + + // N'ajoute pas le bouton s'il n'y a pas d'options avancées + if (this._searchForms.length) { + this.baseSearchEngine.optionscontainer.appendChild(advancedBtn); + } + + // Ajout des options avancées + const eraseBtn = this.eraseBtn = document.createElement("button"); + eraseBtn.className = "GPSearchEngine-erase-btn fr-btn fr-btn--sm fr-icon-close-circle-fill fr-btn--tertiary-no-outline"; + eraseBtn.id = Helper.getUid("GPSearchEngine-erase-btn-"); + eraseBtn.type = "button"; + eraseBtn.title = "Effacer la saisie"; + eraseBtn.setAttribute("aria-label", "Effacer la saisie"); + // Gestion du bouton avancé + eraseBtn.addEventListener("click", function () { + this.baseSearchEngine.input.value = ""; + delete this.baseSearchEngine.input.dataset.empty; + // Notifie l'input du changement + this.baseSearchEngine.input.dispatchEvent(new Event("input")); + // Met le focus sur l'input + console.log("click"); + setTimeout(() => { + this.baseSearchEngine.input.focus(); + }, 50); + }.bind(this)); + this.baseSearchEngine.optionscontainer.appendChild(eraseBtn); + } + + /** + * Fonction active si la recherche avancée est active + * @param {PointerEvent} e Événement de clic sur le document + */ + _onDocumentClick (e) { + if (this.listenToClick === true) { + // Écoute des clics sur le document ==> recherche avancée active + const clickOnAdvancedContainer = (this.advancedContainer === e.target || this.advancedContainer.contains(e.target)); + const clickOnAdvancedBtn = this.advancedBtn === e.target; + if (!(clickOnAdvancedContainer || clickOnAdvancedBtn)) { + // On fait une action si un clic se produit en dehors du conteneur + // Et si le bouton de recherche avancée n'est pas cliqué + this.listenToClick = false; + this.advancedBtn.setAttribute("aria-expanded", false); + } + } + } + + /** + * Ajoute les résultats (features) sur la carte et ajuste la vue. + * @param {Object} e Événement de recherche contenant result/extent + */ + addResultToMap (e) { + this._closePopup(); + this.layer.getSource().clear(); + let extent; + if (!!e.result) { + this.layer.getSource().addFeature(e.result); + extent = e.result.getGeometry().getExtent(); + this.selectInteraction.getFeatures().push(e.result); + this._setPopupInfo(e.result); + } + if (!!e.extent) { + this.layer.getSource().addFeature(e.extent); + extent = e.extent.getGeometry().getExtent(); + } + if (this.getMap()) { + let view = this.getMap().getView(); + if (extent) { + view.fit(extent); + if (view.getZoom() > 15) { + view.setZoom(15); + } + } + } + } + + /** + * Ajoute les infos au popup + * @param {Feature} [feature] Feature à ajouter. Si non fourni + * @param {Number[]} [position] Position du popup + */ + _setPopupInfo (feature, position) { + if (feature) { + // Ferme l'ancien popup + this.popup.setPosition(undefined); + let offset = null; + // Ajoute le popup + if (feature.getGeometry()?.getType() === "Point") { + // Place le popup sur le point + position = feature.getGeometry()?.getCoordinates(); + // TODO : AMÉLIORER L'OFFSET + offset = [0, -20]; + } + this.popup.setPosition(position); + offset && this.popup.setOffset(offset); + this.setPopupContent(feature.get("infoPopup") || ""); + this.popup.set("feature", feature); + this.popup.set("layer", this.layer); + } else { + this.popup.setPosition(undefined); + this.setPopupContent(""); + this.popup.unset("feature"); + this.popup.unset("layer"); + } + } + + /** + * Callback lors de la sélection d'une feature (affiche le popup). + * @param {SelectEvent} e Événement de sélection + * @private + */ + _onSelectElement (e) { + let position = e.mapBrowserEvent.coordinate; + this._setPopupInfo(e.selected.length ? e.selected[0] : null, position); + } + + /** + * Crée et retourne l'overlay popup pour afficher les infos de feature. + * @private + * @param {PopupButton[]} popupButtons - Bouton à ajouter dans le popup (en plus de la suppression / fermeture). + * @returns {Overlay} Overlay du popups + */ + _createPopup (popupButtons) { + // Popup global + let element = this._popupDiv = document.createElement("div"); + // TODO : ajouter gp-feature-info-div lorsque les deux seront pareils + element.className = "GPSearchPopup"; + + // Contenu du popup + let popupContent = this._popupContent = document.createElement("div"); + popupContent.className = "GPPopupContent"; + + // Groupe de boutons + let popupBtns = this._popupBtns = document.createElement("div"); + popupBtns.className = "GPButtonGroups gpf-btns-group"; + + popupBtns.appendChild(this._addCloseButton()); + popupBtns.appendChild(this._addRemoveButton()); + + popupButtons.forEach(popupBtn => { + popupBtns.appendChild(this._createCustomPopupButton(popupBtn)); + }); + + element.appendChild(popupContent); + element.appendChild(popupBtns); + + const overlay = new Overlay({ + element : element, + positioning : "bottom-center", + }); + + return overlay; + } + + /** + * Définit le contenu HTML du popup. + * @param {String} content Contenu HTML à afficher + */ + setPopupContent (content) { + this._popupContent.innerHTML = content; + } + + /** + * Crée le bouton de fermeture du popup. + * @returns {HTMLButtonElement} Bouton de fermeture + * @private + */ + _addCloseButton () { + let closer = document.createElement("button"); + closer.title = closer.ariaLabel = "Fermer la pop-up"; + closer.textContent = "Fermer"; + closer.className = "GPButton gpf-btn fr-icon-close-line fr-btn fr-btn--sm gpf-btn--tertiary fr-btn--tertiary-no-outline"; + + // Ferme le popup + closer.onclick = this._closePopup.bind(this); + return closer; + } + + /** + * Ferme le popup et désélectionne la feature. + * @returns {Boolean} false + * @private + */ + _closePopup () { + this.selectInteraction.getFeatures().clear(); + if (this.popup !== null) { + this.popup.setPosition(undefined); + } + return false; + } + + /** + * Crée le bouton de suppression du marqueur. + * @returns {HTMLButtonElement} Bouton de suppression + * @private + */ + _addRemoveButton () { + let remove = document.createElement("button"); + remove.title = remove.ariaLabel = "Supprimer le marqueur"; + remove.textContent = "Supprimer"; + remove.className = "GPButton gpf-btn fr-icon-delete-line fr-btn fr-btn--sm gpf-btn--tertiary fr-btn--tertiary-no-outline"; + + // Supprime la feature + remove.onclick = this._removeFeature.bind(this); + + return remove; + } + + /** + * Supprime la feature sélectionnée de la couche et ferme le popup. + * @private + */ + _removeFeature () { + const f = this.popup.get("feature"); + const layer = this.popup.get("layer"); + // Supprime la feature + if (layer && f) { + layer.getSource().removeFeature(f); + + this.dispatchEvent({ + type : this.REMOVE_FEATURE_EVENT, + feature : f, + layer : layer, + }); + + // Ferme le popup + this._closePopup(); + } + } + + /** + * Crée un bouton personnalisé pour le popup. + * @param {PopupButton} popupButton - Configuration du bouton. + * @returns {HTMLButtonElement} Bouton HTML + */ + _createCustomPopupButton (popupButton) { + const btn = document.createElement("button"); + btn.title = btn.ariaLabel = popupButton.label; + btn.className = "GPButton fr-btn fr-btn--sm fr-btn--tertiary-no-outline "; + if (popupButton.className) { + btn.className += popupButton.className; + } + if (popupButton.icon) { + btn.classList.add(popupButton.icon); + } + if (popupButton.attributes) { + Object.entries(popupButton.attributes).forEach(([key, value]) => { + btn.setAttribute(key, value); + }); + } + btn.onclick = () => { + const feature = this.popup.get("feature"); + if (feature && typeof popupButton.onClick === "function") { + // New feature sans style + const newFeature = feature.clone(); + newFeature.setStyle(undefined); + // Appel du callback + if (popupButton.onClick.call(this, newFeature)) { + // Feature traitée => supprimer de la sélection + this._closePopup(); + this.selectInteraction.getFeatures().clear(); + this.layer.getSource().removeFeature(feature); + }; + } + }; + return btn; + } + + /** + * Crée le bouton de géolocalisation. + * @returns {HTMLButtonElement} Bouton de géolocalisation + * @private + */ + _getGeolocButton () { + const locationBtn = document.createElement("button"); + locationBtn.innerText = "Me géolocaliser"; + locationBtn.className = "GPSearchEngine-locate fr-btn fr-btn--sm fr-icon-crosshair-2-line fr-btn--icon-left fr-btn--tertiary-no-outline"; + locationBtn.addEventListener("click", () => { + this.geolocation.setTracking(true); + console.log("tracking", this.geolocation); + }); + return locationBtn; + } + + /** + * Callback lors d'un résultat de recherche avancée. + * @param {Object} e Événement de recherche avancée + * @private + */ + onAdvancedSearchResult (e) { + // Ferme la modale de recherche avancée + this.listenToClick = false; + this.advancedBtn.setAttribute("aria-expanded", false); + // Ferme les sections + this.closeAllSections(); + if (e.result instanceof Array) { + // TODO : GÉRER MULTIPLE RÉSULTATS + } else if (e.result instanceof Feature) { + this.addResultToMap(e); + } + } + + /** + * Ferme toutes les accordéons et affiche les sections + */ + closeAllSections () { + this.advancedContainer.dataset.open = false; + this.advancedContainer.querySelectorAll("section").forEach(section => { + const btn = section.querySelector(".fr-accordion__title > button[aria-expanded]"); + btn.setAttribute("aria-expanded", "false"); + btn.ariaControlsElements[0].classList.remove("fr-collapse--expanded"); + section.classList.remove("fr-hidden"); + }); + } + +} + +export default SearchEngineAdvanced; + +// Expose SearchEngine as ol.control.SearchEngine (for a build bundle) +if (window.ol && window.ol.control) { + window.ol.control.SearchEngineAdvanced = SearchEngineAdvanced; +} \ No newline at end of file diff --git a/src/packages/Controls/SearchEngine/SearchEngineBase.js b/src/packages/Controls/SearchEngine/SearchEngineBase.js new file mode 100644 index 000000000..8a0c6ad66 --- /dev/null +++ b/src/packages/Controls/SearchEngine/SearchEngineBase.js @@ -0,0 +1,703 @@ +// import CSS +import "../../CSS/Controls/SearchEngine/GPFsearchEngine.css"; +import Control from "../Control"; +import Logger from "../../Utils/LoggerByDefault"; +import DefaultSearchService from "../../Services/DefaultSearchService"; +import Helper from "../../Utils/Helper"; + +// Voir les typedefs partagés dans ./typedefs.js (SearchEngineBaseOptions, SearchServiceOptions, ...) + +const history = "fr-icon-history-line"; +const defaultIcon = "fr-icon-map-pin-2-line"; +const typeClasses = { + "StreetAddress" : "fr-icon-map-pin-2-line", + "PositionOfInterest" : { + "administratif" : "fr-icon-france-line", + "hydrographie" : "fr-icon-ign-mer", + "transport" : { + "télécabine, téléphérique transport par câble" : "fr-icon-subway-line", + "gare voyageurs uniquement" : "fr-icon-subway-line", + "voyageurs et fret" : "fr-icon-subway-line", + "station de métro" : "fr-icon-subway-line", + "gare fret uniquement" : "fr-icon-subway-line", + "gare routière" : "fr-icon-subway-line", + "station de tramway" : "fr-icon-subway-line", + "arrêt voyageurs" : "fr-icon-subway-line", + + "aérodrome" : "fr-icon-plane-line", + "héliport" : "fr-icon-plane-line", + "altiport" : "fr-icon-plane-line", + "aérogare" : "fr-icon-plane-line", + + "port" : "fr-icon-ship-2-line", + "gare maritime" : "fr-icon-ship-2-line", + + "parking" : "fr-icon-parking-box-line", + "aire de repos ou de service" : "fr-icon-parking-box-line", + + "service dédié aux vélos" : "fr-icon-bike-line", + } + } +}; + +var logger = Logger.getLogger("searchengine"); + +/** + * @classdesc + * Contrôle de base pour la recherche (barre de recherche, autocomplétion, historique). + * + * @alias ol.control.SearchEngineBase + * @module SearchEngine + */ +class SearchEngineBase extends Control { + + /** + * Constructeur du contrôle SearchEngineBase. + * @constructor + * @param {SearchEngineBaseOptions} options Options du constructeur + * @fires autocomplete + * @fires search + * @fires select + * @example + * const search = new ol.control.SearchEngineBase({ + * placeholder: "Rechercher une adresse...", + * minChars: 3, + * maximumEntries: 10, + * historic: "mesRecherches", + * searchService: new CustomSearchService() + * }); + * map.addControl(search) + */ + constructor (options) { + options = options || {}; + // call ol.control.Control constructor + super(options); + + /** + * Nom de la classe (heritage) + * @private + */ + this.CLASSNAME = "SearchEngineBase"; + + // initialisation du composant + this.initialize(options); + + this.searchService = options.searchService; + // Permet l'autocomplétion + if (this.searchService.get("autocomplete") !== false) { + this.searchService.on("autocomplete", function (e) { + this.onAutocomplete(e); + }.bind(this)); + } + + this.searchService.on("search", function (e) { + this.onSearch(e); + }.bind(this)); + + // Widget main DOM container + this._initContainer(options); + + this._initEvents(options); + + // Get historic in localStorage + this._historic = false; + this._historicName = "GPsearch-" + options.historic; + if (options.historic !== false && this.searchService.get("autocomplete") !== false) { + this._historic = []; + try { + const stor = window.localStorage.getItem(this._historicName); + if (stor) { + this._historic = JSON.parse(stor); + } + } catch (e) { + // logger.warn("LocalStorage not available"); + } + } + this.showHistoric(); + } + /** + * Initialise le contrôle SearchEngineBase (appelé par le constructeur). + * @protected + * @param {SearchEngineBaseOptions} options Options du constructeur + */ + initialize (options) { + // Valeurs par défaut des options + options.minChars = options.minChars ? options.minChars : 3; + options.maximumEntries = (typeof options.maximumEntries === "number") ? options.maximumEntries : 5; + options.historic = (typeof options.historic === "string" ? options.historic : this.CLASSNAME); + options.title = options.title ? options.title : "Rechercher"; + options.ariaLabel = options.ariaLabel ? options.ariaLabel : "Rechercher"; + options.placeholder = options.placeholder ? options.placeholder : ""; + options.searchService = options.searchService ? options.searchService : new DefaultSearchService(); + options.label = options.label ? options.label : ""; + options.hint = options.hint ? options.hint : ""; + options.search = options.search === true ? true : false; + options.searchButton = options.searchButton === true ? true : false; + options.collapsible = options.collapsible === true ? true : false; + + this.set("maximumEntries", options.maximumEntries); + } + + /** + * Ajoute les écouteurs d'événements sur les éléments du contrôle. + * @protected + * @param {SearchEngineBaseOptions} options Options du constructeur + */ + _initEvents (options) { + if (this.searchService.get("autocomplete") !== false) { + // Empty input + this.input.addEventListener("input", function (e) { + if (e.target.value.length > 0) { + e.target.dataset.erase = true; + } else { + delete e.target.dataset.erase; + } + console.log("input", e, e.target.value); + if (!e.target.value) { + this.showHistoric(); + } + }.bind(this)); + // Prevent cursor to go to the end of input on keydown + this.input.addEventListener("keydown", function (e) { + if (/ArrowDown|ArrowUp/.test(e.key)) { + e.preventDefault(); + } + }.bind(this)); + // Keyboard navigation + this.input.addEventListener("keyup", function (e) { + // autocomplete list + const list = Array.from(this.autocompleteList.querySelectorAll("li")); + let idx = list.findIndex(li => li.classList.contains("active")); + if (idx === -1) { + // Ancienne valeur + this._previousValue = e.target.value; + } + switch (e.key) { + case "ArrowDown": + case "ArrowUp": + e.preventDefault(); + // Navigation in autocomplete list + if (list.length === 0) { + return; + } + list.forEach(li => li.classList.remove("active")); + if (e.key === "ArrowDown") { + idx++; + if (idx >= list.length) { + idx = -1; + } + } else if (e.key === "ArrowUp") { + idx--; + if (idx < -1) { + idx = list.length - 1; + } + } + if (idx !== -1) { + // Set active + const current = list[idx]; + current.classList.add("active"); + this.input.value = current.innerText; + this.input.setAttribute("aria-activedescendant", current.id); + this.input.setAttribute("data-active-option", current.id); + } else { + // Réaffiche la valeur précédente de l'utilisateur + e.target.value = this._previousValue; + } + // Envoie un événement de type input pour notifier le changement + this.input.dispatchEvent(new Event("input")); + + break; + default: + if (e.target.value.length && e.target.value.length >= options.minChars && e.target.value !== this._currentValue) { + this.autocomplete(e.target.value); + } + break; + } + this._currentValue = e.target.value; + }.bind(this), false); + } + + // Événement d'envoi du formulaire + this.container.addEventListener("submit", function (e) { + e.preventDefault(); + const list = Array.from(this.autocompleteList.querySelectorAll("li")); + + if (e && e.submitter && e.submitter.type === "submit") { + // Si on appuie sur le bouton, on vérifie que l'input ne soit pas vide + let input = e.target.querySelector("input"); + const value = input.value; + if (value.length < options.minChars) { + return false; + } + } + let idx = list.findIndex(li => li.classList.contains("active")); + let item = list[idx]; + if (idx < 0) { + // Pas d'item sélectionné : on prend le premier de la liste + item = list[0]; + } + if (item) { + item.click(); + } + }.bind(this)); + } + /** + * Initialise le conteneur DOM principal du contrôle. + * @private + * @param {SearchEngineBaseOptions} options Options du constructeur + * @returns {void} + */ + _initContainer (options) { + const element = this.element = document.createElement("div"); + element.className = "GPwidget gpf-widget"; + element.id = Helper.getUid("GPsearchEngine-"); + // Main container + const container = this.container = document.createElement("form"); + // container.className = options.search ? "GPSearchBar fr-search-bar" : ""; + container.className = options.search ? "GPSearchBar" : ""; + // container.className = "fr-search-bar"; + container.id = Helper.getUid("GPsearchInput-Base-"); + + // Création du bouton + if (!options.target && options.collapsible) { + this.button = document.createElement("button"); + this.button.id = Helper.getUid("GPshowSearchEnginePicto-"); + this.button.className = "GPshowOpen GPshowAdvancedToolPicto GPshowSearchEnginePicto gpf-btn fr-icon-search-line fr-btn fr-btn--lg"; + this.button.setAttribute("aria-pressed", "true"); + // this.button.setAttribute("type", "submit"); + // this.button.setAttribute("form", container.id); + if (options.title) { + this.button.setAttribute("title", options.title); + } + if (options.collapsible) { + this.button.addEventListener("click", function () { + element.classList.toggle("ol-collapsed"); + const pressed = this.button.getAttribute("aria-pressed") === "true"; + this.button.setAttribute("aria-pressed", !pressed); + if (!pressed) { + input.focus(); + } else { + input.blur(); + } + }.bind(this)); + } + element.appendChild(this.button); + } + + element.appendChild(container); + + const search = document.createElement("div"); + // search.className = "GPInputGroup fr-input"; + search.className = "GPInputGroup"; + search.classList.add(options.search ? "fr-input" : "fr-input-group"); + container.appendChild(search); + + // Input + const input = this.input = document.createElement("input"); + input.type = "text"; + input.className = "GPsearchInputText fr-input"; + input.id = Helper.getUid("GPsearchInputText-"); + input.placeholder = options.placeholder; + input.autocomplete = "off"; + input.setAttribute("aria-label", options.ariaLabel); + + if (options.label) { + const label = document.createElement("label"); + label.className = "GPLabel fr-label"; + label.textContent = options.label; + label.htmlFor = input.id; + if (options.hint) { + const hint = document.createElement("span"); + hint.className = "GPLabelHint fr-hint-text"; + hint.textContent = options.hint; + label.appendChild(hint); + } + search.appendChild(label); + } + search.appendChild(input); + + const messages = document.createElement("div"); + messages.className = "GPMessagesGroup fr-messages-group"; + messages.ariaLive = "polite"; + messages.id = Helper.getUid("GPMessagesGroup-"); + input.setAttribute("aria-describedby", messages.id); + search.appendChild(messages); + + // Options container + this.optionscontainer = document.createElement("div"); + this.optionscontainer.className = "GPOptionsContainer"; + search.appendChild(this.optionscontainer); + + // Submit button + if (options.searchButton) { + const submit = this.subimtBt = document.createElement("button"); + submit.className = "GPsearchInputSubmit gpf-btn fr-icon-search-line fr-btn"; + submit.id = Helper.getUid("GPshowSearchEnginePicto-"); + submit.type = "submit"; + if (options.title) { + submit.setAttribute("title", options.title); + } + container.appendChild(submit); + } + + // Autocomplete container + const acContainer = this.acContainer = document.createElement("div"); + acContainer.id = Helper.getUid("GPautoCompleteContainer-"); + acContainer.className = "GPautoCompleteContainer GPelementHidden gpf-hidden"; + element.appendChild(acContainer); + // element.appendChild(acContainer); + + // Autocomplete list + const autocompleteHeader = this.autocompleteHeader = document.createElement("div"); + autocompleteHeader.className = "GPautoCompleteHeader"; + acContainer.appendChild(autocompleteHeader); + + const autocompleteList = this.autocompleteList = document.createElement("ul"); + autocompleteList.className = "GPautoCompleteList"; + autocompleteList.id = Helper.getUid("GPautoCompleteList-"); + autocompleteList.setAttribute("role", "listbox"); + autocompleteList.setAttribute("tabindex", "-1"); + autocompleteList.setAttribute("aria-label", "Propositions"); + acContainer.appendChild(autocompleteList); + + const autocompleteFooter = this.autocompleteFooter = document.createElement("div"); + autocompleteFooter.className = "GPautoCompleteFooter"; + acContainer.appendChild(autocompleteFooter); + + // Input controller for accessibility + input.setAttribute("role", "combobox"); + input.setAttribute("aria-controls", acContainer.id); + input.setAttribute("aria-expanded", "false"); + input.setAttribute("aria-autocomplete", "list"); + input.setAttribute("aria-haspopup", "listbox"); + + if (this.searchService.get("autocomplete") !== false) { + input.addEventListener("focus", () => { + input.setAttribute("aria-expanded", "true"); + acContainer.classList.add("gpf-visible"); + acContainer.classList.remove("gpf-hidden"); + acContainer.classList.add("GPelementVisible"); + acContainer.classList.remove("GPelementHidden"); + }); + + // Gère le focus pour la sélection d'éléments dans la liste + input.addEventListener("blur", (e) => { + // N'agit que si le focus est hors de l'élément + if (e.relatedTarget && acContainer.contains(e.relatedTarget)) { + // N'empêche pas le focus sur un bouton + if (!(e.relatedTarget.tagName === "BUTTON")) { + input.focus(); + } else { + // Ajout d'un event listener pour retourner sur l'input en cas de besoin + e.relatedTarget.addEventListener("blur", (/** @type {FocusEvent}*/ e) => { + if (e.relatedTarget && acContainer.contains(e.relatedTarget) || e.relatedTarget === input) { + input.focus(); + } else { + // On doit aller sur le bouton recherche avancée + if (input.value.length === 0) { + delete input.dataset.erase; + e.relatedTarget.parentNode.querySelector("button")?.focus(); + } + setTimeout(() => { + input.setAttribute("aria-expanded", "false"); + acContainer.classList.remove("gpf-visible"); + acContainer.classList.add("gpf-hidden"); + acContainer.classList.remove("GPelementVisible"); + acContainer.classList.add("GPelementHidden"); + }, 50); + } + }, { once : true }); + } + } else { + input.value.length === 0 && delete input.dataset.erase; + setTimeout(() => { + input.setAttribute("aria-expanded", "false"); + acContainer.classList.remove("gpf-visible"); + acContainer.classList.add("gpf-hidden"); + acContainer.classList.remove("GPelementVisible"); + acContainer.classList.add("GPelementHidden"); + }, 50); + } + }); + } + } + + /** + * Active ou désactive le contrôle (désactive l'input / bouton). + * @param {Boolean} active Indique si le contrôle doit être désactivé + * @returns {void} + */ + setActive (active) { + this.input.disabled = !!active; + this.subimtBt.disabled = !!active; + } + + /** + * Lance l'autocomplétion et met à jour la liste. + * @param {String} [value] Valeur de l'input + * @api + */ + autocomplete (value) { + clearTimeout(this._completeDelay); + this._completeDelay = setTimeout(function () { + this.searchService.autocomplete(value); + }.bind(this), this.get("triggerDelay") || 100); + } + + /** + * Callback sur événement d'autocomplétion. + * @param {Object} e Événement d'autocomplétion + * @private + */ + onAutocomplete (e) { + clearTimeout(this._completeDelay); + // Update list} + this._updateList(e.result); + this.dispatchEvent(e); + } + + /** + * Lance la recherche de géocodage. + * @param {IGNSearchObject} item Valeur ou objet à rechercher + * @api + */ + search (item) { + clearTimeout(this._completeDelay); + this._completeDelay = setTimeout(function () { + this.searchService.search(item); + }.bind(this), this.get("triggerDelay") || 100); + } + + /** + * Callback sur événement de recherche. + * @param {Object} e Événement de recherche + * @api + */ + onSearch (e) { + clearTimeout(this._completeDelay); + // Update list} + this.dispatchEvent(e); + } + + /** + * Callback sur sélection d'un item. + * @param {Object} item Élément sélectionné + * @api + */ + select (item) { + clearTimeout(this._completeDelay); + const title = this.getItemTitle(item); + this.input.value = title; + this.input.dispatchEvent(new Event("input")); + this._currentValue = title; + this._updateHistoric(item); + this._updateList(); + this.dispatchEvent({ + type : "select", + title : this.getItemTitle(item), + item : item + }); + } + + /** + * Affiche la liste de l'historique. + * @api + */ + showHistoric () { + clearTimeout(this._completeDelay); + if (this._historic) { + this._updateList(this._historic.length ? this._historic : [], "history"); + } + } + + /** + * Met à jour la liste d'autocomplétion. + * @private + * @param {Array} tab Liste des items d'autocomplétion + * @param {String} [type="search"] Type d'affichage ("history" ou "search") + */ + _updateList (tab, type = "search") { + this.autocompleteList.parentNode.dataset.type = type; + // + tab = (tab || []).slice(0, this.get("maximumEntries")); + // Accessibility + this.autocompleteList.querySelectorAll("li").forEach(li => li.classList.remove("active")); + this.input.setAttribute("aria-activedescendant", ""); + this.input.setAttribute("data-active-option", ""); + // Update list + this.autocompleteList.innerHTML = ""; + tab.forEach((item, idx) => { + const li = document.createElement("li"); + const iconClass = this.getIconClass(item, type); + + li.id = Helper.getUid("GPsearchResult-"); + li.className = `GPsearchResult gpf-panel__item gpf-panel__item-searchengine ${iconClass} fr-icon--sm`; + li.setAttribute("role", "option"); + li.setAttribute("data-idx", idx); + li.innerHTML = li.title = this.getItemTitle(item); + if (type === "history") { + li.classList.add("GPsearchHistoric"); + const span = document.createElement("span"); + span.ariaHidden = "true"; + span.className = `${history} fr-icon--sm`; + li.append(span); + } + this.autocompleteList.appendChild(li); + li.addEventListener("click", function (/** @type {PointerEvent} */ e) { + const idx = Number(e.target.getAttribute("data-idx")); + // Sélectionne l'item + this.select(tab[idx]); + // Lance la recherche pour cet item + this.search({ + location : tab[idx] + }); + }.bind(this)); + }); + } + + /** + * Retourne la classe à ajouter pour un résultat d'autocomplétion + * @param {AutocompleteResult} item Résultat de l'autocomplétion (ou historique) + * @param {String} type Type de la recherche ("history" ou "search") + * @returns {String} classe à ajouter + */ + getIconClass (item, type) { + // let iconClass = typeClasses[type]; + let iconClass = typeClasses[item.type]; + // Cas où l'on a d'autres éléments + if (typeof iconClass === "object") { + // Cherche les types de POI + for (let i = 0; i < item.poiType.length; i++) { + const poiType = item.poiType[i]; + if (Object.hasOwn(iconClass, poiType)) { + iconClass = iconClass[poiType]; + break; + } + } + + // TODO : améliorer la fonction (faire récursif ?) + if (typeof iconClass === "object") { + // Cherche les types de POI + for (let i = 0; i < item.poiType.length; i++) { + const poiType = item.poiType[i]; + if (Object.hasOwn(iconClass, poiType)) { + iconClass = iconClass[poiType]; + break; + } + } + } + iconClass = typeof iconClass === "object" ? defaultIcon : iconClass; + } + return iconClass; + } + + /** + * Retourne le titre à afficher pour un item. + * @param {Object} item Élément à afficher + * @returns {String} Titre + * @api + */ + getItemTitle (item) { + return this.searchService.getItemTitle(item); + } + + /** + * Ajoute ou remplace une valeur dans l'historique. + * @private + * @param {Object} value Valeur à ajouter + */ + _updateHistoric (value) { + if (this._historic) { + let index = -1; + for (let i = 0; i < this._historic.length; i++) { + const elem = this._historic[i]; + if (this._isEqual(elem, value)) { + // L'élément est déjà dans l'historique + index = i; + break; + } + } + if (index !== -1) { + // Enlève de l'historique pour le remettre en première position; + this._historic.splice(index, 1); + } + + const length = this._historic.unshift(value); + // Retire le dernier élément si le nombre maximal est atteint + if (length > (this.get("maximumEntries"))) { + this._historic.pop(); + } + + // Enregistre dans le localStorage + localStorage.setItem(this._historicName, JSON.stringify(this._historic)); + } + } + + /** + * Vérifie si deux éléments (objets) sont égaux. + * @private + * @param {Object} a Premier objet + * @param {Object} b Objet de comparaison + * @returns {Boolean} true si égal, false sinon + */ + _isEqual (a, b) { + // TODO : Améliorer comparaison ? + const jsonA = JSON.stringify(a); + const jsonB = JSON.stringify(b); + + return jsonA === jsonB; + } + + /** + * Ajoute un message à un champ de saisie. + * @param {String} message Message à afficher + * @param {String} [type="error"] Type du message ("error" ou "valid") + * @api + */ + addMessage (message, type = "error") { + let messageElement = this.input.ariaDescribedByElements[0]; + if (messageElement) { + const p = document.createElement("p"); + const messageType = type === "error" ? "error" : "valid"; + p.className = `GPMessage GPMessage--${messageType} fr-message fr-message--${messageType}`; + p.id = Helper.getUid("GPMessage-"); + p.textContent = message; + + // Enlève la classe du type de message à l'élément parent + messageElement.parentElement?.classList.forEach(c => { + if (/^fr-.*-group$/.test(c)) { + messageElement.parentElement?.classList.add(`${c}--${messageType}`); + } + }); + + messageElement.replaceChildren(p); + } + } + + /** + * Enlève les messages d'erreur du champ de saisie. + * @param {HTMLInputElement|HTMLSelectElement} input Champ de saisie + * @api + */ + removeMessages () { + let messageElement = this.input.ariaDescribedByElements[0]; + if (messageElement) { + messageElement.replaceChildren(); + + // Enlève la classe du type de message à l'élément parent + messageElement.parentElement?.classList.forEach(c => { + if (/^fr-.*-group--/.test(c)) { + messageElement.parentElement?.classList.remove(c); + } + }); + } + } + +} + +export default SearchEngineBase; + +// Expose SearchEngine as ol.control.SearchEngine (for a build bundle) +if (window.ol && window.ol.control) { + window.ol.control.SearchEngineBase = SearchEngineBase; +} diff --git a/src/packages/Controls/SearchEngine/SearchEngineGeocodeIGN.js b/src/packages/Controls/SearchEngine/SearchEngineGeocodeIGN.js new file mode 100644 index 000000000..0e6916aa4 --- /dev/null +++ b/src/packages/Controls/SearchEngine/SearchEngineGeocodeIGN.js @@ -0,0 +1,96 @@ +// import CSS +import Logger from "../../Utils/LoggerByDefault"; +import SearchEngineBase from "./SearchEngineBase"; +import AbstractSearchService from "../../Services/AbstractSearchService"; +import IGNSearchService from "../../Services/IGNSearchService"; + +var logger = Logger.getLogger("searchengine"); + +/** + * @file + * Contrôle de recherche spécialisé pour le géocodage IGN. + * Ce contrôle hérite de SearchEngineBase et ajoute la gestion des options spécifiques IGN via la propriété `serviceOptions`. + * Utilisez le typedef {@link SearchEngineGeocodeIGNOptions} pour bénéficier de l'autocomplétion et de la documentation dans VSCode/TypeDoc. + * + * @see SearchEngineBaseOptions + * @see AbstractSearchServiceOptions + */ + +/** + * @classdesc + * Moteur de recherche spécialisé pour le géocodage IGN (implémentation de SearchEngineBase). + * + * @alias ol.control.SearchEngineGeocodeIGN + * @module SearchEngine + * @extends SearchEngineBase + */ +class SearchEngineGeocodeIGN extends SearchEngineBase { + + /** + * Constructeur du contrôle SearchEngineGeocodeIGN. + * + * @constructor + * @param {SearchEngineGeocodeIGNOptions} [options] Options du contrôle. + * @example + * const ctrl = new SearchEngineGeocodeIGN({ + * placeholder: "Rechercher...", + * serviceOptions: { apiKey: "votre-cle", returnTrueGeometry: true } + * }); + */ + constructor (options) { + options = options || {}; + + // call ol.control.Control constructor + super(options); + + return this; + } + + /** + * Initialise les options du contrôle. + * + * @override + * @param {SearchEngineGeocodeIGNOptions} options - Options du constructeur. + */ + initialize (options) { + /** + * Nom de la classe (heritage) + * @private + */ + this.CLASSNAME = "SearchEngineGeocodeIGN"; + this.REMOVE_FEATURE_EVENT = "remove:feature"; + + options.serviceOptions = options.serviceOptions ? options.serviceOptions : {}; + if (options.autocomplete === false) { + this.set("autocomplete", false); + options.serviceOptions.autocomplete = false; + } + + options.serviceOptions.returnTrueGeometry = !!options.returnTrueGeometry; + + // Créé le serbice de géocodage IGN + if (!options.searchService || !(options.searchService instanceof AbstractSearchService)) { + options.searchService = new IGNSearchService(options.serviceOptions); + } + + super.initialize(options); + } + + /** + * Initialise les événements du contrôle. + * + * @override + * @param {SearchEngineGeocodeIGNOptions} options - Options du constructeur. + */ + _initEvents (options) { + super._initEvents(options); + } + +} + +export default SearchEngineGeocodeIGN; + +// Expose SearchEngine as ol.control.SearchEngine (for a build bundle) +if (window.ol && window.ol.control) { + window.ol.control.SearchEngineGeocodeIGN = SearchEngineGeocodeIGN; +} diff --git a/src/packages/Controls/SearchEngine/map-pin-2-fill.svg b/src/packages/Controls/SearchEngine/map-pin-2-fill.svg new file mode 100644 index 000000000..427babc8e --- /dev/null +++ b/src/packages/Controls/SearchEngine/map-pin-2-fill.svg @@ -0,0 +1 @@ + diff --git a/src/packages/Controls/SearchEngine/typedefs.js b/src/packages/Controls/SearchEngine/typedefs.js new file mode 100644 index 000000000..7bde8cde2 --- /dev/null +++ b/src/packages/Controls/SearchEngine/typedefs.js @@ -0,0 +1,166 @@ +/** + * Typedefs partagés pour les contrôles SearchEngine. + * Ce fichier centralise les types réutilisables afin que typedoc génère des docs cohérentes. + */ + +/** + * @typedef {Object} SearchServiceOptions + * @property {Boolean} [autocomplete] - Active/désactive l'autocomplétion côté service. + * @property {Object} [searchOptions] - Options passées au service de recherche. + * @property {Object} [searchOptions.serviceOptions] - Options spécifiques au service. + * @property {string[]} [searchOptions.serviceOptions.fields] - Liste de champs à demander au service. + */ + +/** + * @typedef {Object} SearchEngineOptions + * @property {String} [label] - Libellé associé à l'input. + * @property {String} [hint] - Texte d'aide / placeholder. + * @property {SearchServiceOptions|Object} [searchService] - Configuration du service de recherche. + */ + +/** + * @typedef {Object} TextInputElement + * @property {String} [pattern] - Pattern HTML attendu (chaîne regex). + * @property {String} [title] - Titre / info du champ. + * @property {String} [value] - Valeur courante du champ. + * @property {(e:KeyboardEvent)=>void} [onkeydown] - Gestionnaire keydown. + * @property {(type:string, listener:Function)=>void} [addEventListener] - Ajout d'écouteur. + */ + +/** + * Résultat d'une autocomplétion (voir {@link https://ignf.github.io/geoportal-access-lib/latest/jsdoc/Gp.Services.AutoComplete.SuggestedLocation.html}) + * @typedef {Object} AutocompleteResult + * @property {"StreetAddress"|"PositionOfInterest"} type - Type de suggestion. + * @property {Position} position - Coordonnées du point, dans le système de coordonnées spécifiées. + * @property {String} commune - Nom de la commune. + * @property {String} fullText - Texte complet représentant la suggestion. + * @property {String} postalCode - Code postal de la suggestion. + * @property {Number} classification - Nombre utilisé pour classigier l'importance de l'endroit suggéré de 1 (plus important) à 7 (moins important) + * @property {Array} [poiType] - Types POI détaillés. + * @property {String} [street] - Nom de la rue (types "StreetAddress" seulement). + * @property {String} [kind] - Nature du point d'intérêt, e.g. "prefecture", "municipality"... (types "PositionOfInterest" seulement). + */ + +/** + * Position dans un système de coordonnées. + * @typedef {Object} Position + * @property {Number} x - Longitude. + * @property {Number} y - Latitude. + */ + + +/** + * Erreur du service (voir {@link https://ignf.github.io/geoportal-access-lib/latest/jsdoc/Gp.Error.html}) + * @typedef {Object} ErrorService + * @property {string} message - Message d'erreur retourné par le service. + * @property {number} status - Code de statut (-1 si inconnu). + * @property {string} type - Type d'erreur (ex. "UNKNOWN_ERROR"). + * @property {string} name - Nom de l'erreur (ex. "ErrorService"). + * @property {string} [stack] - Stack trace de l'erreur, si disponible. + */ + + +/** + * Résultat d'une recherche (géocodage final). + * @typedef {Object} SearchResult + * @property {import("ol/Feature").default} feature - Feature OL contenant la géométrie. + * @property {import("ol/Feature").default|undefined} [extent] - Étendue si zone géographique. + * @property {String} [infoPopup] - Texte à afficher dans un popup. + */ + +/** + * Options pour le contrôle SearchEngineBase. + * @typedef {Object} SearchEngineBaseOptions + * @property {HTMLElement|string} [target] - Élément DOM ou sélecteur cible. + * @property {String} [title="Rechercher"] - Texte du titre du bouton. + * @property {String} [label=""] - Label affiché. + * @property {String} [hint=""] - Texte d'aide. + * @property {Boolean} [search=false] - Comportement en tant que barre de recherche. + * @property {String} [collapsible=false] - Si vrai, le contrôle est repliable. + * @property {String} [ariaLabel="Rechercher"] - Libellé ARIA. + * @property {String} [placeholder=""] - Placeholder de l'input. + * @property {Number} [minChars=0] - Nombre minimal de caractères pour autocomplétion. + * @property {Number} [maximumEntries=5] - Nombre maximal d'entrées affichées. + * @property {boolean|string} [historic=true] - Gestion historique local (false|true|string). + * @property {import("../../Services/AbstractSearchService").default} [searchService] - Service de recherche. + */ + +/** + * Options pour AbstractAdvancedSearch (formulaires avancés). + * @typedef {Object} AbstractAdvancedSearchOptions + * @property {String} name - Nom du formulaire de recherche avancée. + */ + +/** + * Événement de recherche générique. + * @typedef {Object} SearchEvent + * @property {String} type - Type d'événement (ex. "search"). + * @property {Object} [detail] - Payload détaillé. + */ + +/** + * Description d'un système de projection utilisable par le contrôle CoordinateAdvancedSearch. + * @typedef {Object} CoordinateSearchSystem + * @property {String} crs - Alias CRS (ex. "EPSG:4326"). + * @property {String} [label] - Libellé affiché pour le système. + * @property {String} [type] - Type d'unités ("Geographical"|"Metric"). + */ + +/** + * Options spécifiques à la recherche par coordonnées. + * @typedef {Object} CoordinateSearchOptions + * @property {CoordinateSearchSystem[]} [systems] - Liste de systèmes de projection personnalisés. + * @property {string[]} [units] - Liste de codes d'unités à afficher (ex. ["DEC","DMS","M","KM"]). + */ + +/** + * Options pour le contrôle SearchEngineGeocodeIGN. + * Étend SearchEngineBaseOptions et ajoute les options spécifiques du service IGN. + * @typedef {SearchEngineBaseOptions & { + * serviceOptions?: AbstractSearchServiceOptions + * }} SearchEngineGeocodeIGNOptions + */ + + +/** + * Options pour le contrôle SearchEngineGeocodeIGN. + * Étend SearchEngineBaseOptions et ajoute les options spécifiques du service IGN. + * @typedef {AbstractAdvancedSearchOptions & {coordinateSearch?: CoordinateSearchOptions}} CoordinateAdvancedSearchOptions + */ + +/** + * Options pour ajouter un bouton de popup + * @typedef {Object} PopupButton + * @property {string} label - Attribut title du bouton. + * @property {string} [className] - Classe(s) CSS à appliquer au bouton. + * @property {string} [icon] - Classe d'icône à ajouter (ex: "fr-icon-delete-line"). + * @property {Object.} [attributes] - Attributs HTML supplémentaires (clé/valeur). + * @property {PopupButtonClickCallback} onClick - Fonction appelée au clic sur le bouton. + */ + +/** + * Callback appelé lors du clic sur un bouton personnalisé du popup. + * @callback PopupButtonClickCallback + * @param {Feature} feature - La feature associée au popup. + * @returns {Boolean} Retourne true si la feature doit être supprimée de la sélection, default (false). + * @this SearchEngineAdvanced + */ + +/** + * Options pour le contrôle SearchEngineAdvanced. + * @typedef {Object} SearchEngineAdvancedOptions + * @property {import("./AbstractAdvancedSearch").default[]} [advancedSearch] - Liste des recherches avancées à intégrer. + * @property {PopupButton[]} [popupButtons] - Boutons personnalisés à ajouter dans le popup. + * @property {SearchEngineGeocodeIGNOptions} [baseSearchOptions] - Options pour le moteur de recherche de base. + * @property {HTMLElement|String} [target] - Élément DOM ou sélecteur cible. + * @property {String} [title] - Titre du contrôle. + * @property {String} [label] - Label affiché. + * @property {String} [hint] - Texte d'aide. + * @property {Boolean} [search] - Comportement en tant que barre de recherche. + * @property {String} [ariaLabel] - Libellé ARIA. + * @property {String} [placeholder] - Placeholder de l'input. + * @property {Number} [minChars] - Nombre minimal de caractères pour autocomplétion. + * @property {Number} [maximumEntries] - Nombre maximal d'entrées affichées. + * @property {Boolean|String} [historic] - Gestion historique local (false|true|string). + * @property {import("../../Services/AbstractSearchService").default} [searchService] - Service de recherche. + */ \ No newline at end of file diff --git a/src/packages/Interactions/Drawing.js b/src/packages/Interactions/Drawing.js new file mode 100644 index 000000000..a8e57e53e --- /dev/null +++ b/src/packages/Interactions/Drawing.js @@ -0,0 +1,263 @@ +// import CSS +import "../CSS/Interactions/Drawing.css"; + +import Interaction from "ol/interaction/Interaction"; +import Draw from "ol/interaction/Draw"; +import Style from "ol/style/Style"; +import ImageStyle from "ol/style/Image"; +import Icon from "ol/style/Icon"; +import Stroke from "ol/style/Stroke"; +import Fill from "ol/style/Fill"; +import CircleStyle from "ol/style/Circle"; +import MultiPoint from "ol/geom/MultiPoint"; +import coordinate from "ol/coordinate"; +import Control from "ol/control/Control"; + +import mapPinIcon from "../Controls/SearchEngine/map-pin-2-fill.svg"; +import VectorSource from "ol/source/Vector"; + +/** Get all points in coordinates + * @param {Array} coords + * @returns {Array} + * @private + */ +function getFlatCoordinates (coords) { + if (coords && coords[0].length && coords[0][0].length) { + var c = []; + for (var i=0; i { + if (!this._currentFeature) { + return; + } + switch (evt.key) { + // Finish drawing on Enter or double click + case "Enter": { + this.draw.finishDrawing(); + break; + } + // Remove last point on Backspace + case "Backspace": { + this.draw.removeLastPoint(); + break; + } + } + }; + + // Info control + this.info = new InfoControl(); + + // Draw interaction + this.draw = new Draw({ + // source : options.source, + type : this._type, + style : (feature, resolution) => this.getStyle(feature, resolution), + }); + // Draw interaction events + this.draw.on("drawstart", (e) => { + this.showInfo("Double-cliquer ou appuyer sur Entrée pour terminer."); + this._currentFeature = e.feature; + document.addEventListener("keydown", onkeydown); + this.dispatchEvent(e); + }); + this.draw.on(["drawend","drawabort"], (e) => { + if (e.type === "drawend") { + const source = this.getSource(); + if (source) { + source.addFeature(e.feature); + } + } + this.showInfo(this.getActive() ? "Cliquer pour commencer." : ""); + this._currentFeature = null; + document.removeEventListener("keydown", onkeydown); + this.dispatchEvent(e); + }); + } + + /** + * Overwrite OpenLayers setMap method + * + * @param {Map} map - Map. + */ + setMap (map) { + if (this.getMap()) { + this.getMap().removeInteraction(this.draw); + this.getMap().removeControl(this.info); + // reset cursor + this.getMap().getTargetElement().style.cursor = ""; + } + super.setMap(map); + // Additional setup can be done here if needed + if (map) { + map.addInteraction(this.draw); + map.addControl(this.info); + } + this.setActive(this.getActive()); + } + + /** Change the source to collect features + * @param {VectorSource} source Vector source + */ + setSource (source) { + this._source = source || null; + } + + /** The source to collect features + * @param {VectorSource} source Vector source + */ + getSource () { + return this._source || null; + } + + /** Set interaction active state + * @param {Boolean} active Active state + */ + setActive (active) { + super.setActive(active); + if (this.draw) { + this.draw.setActive(active); + this.showInfo(active ? "Cliquer pour commencer." : ""); + } + if (this.getMap()) { + this.getMap().getTargetElement().style.cursor = this.getActive() ? "crosshair" : ""; + } + } + + /** Get drawing style + * @returns {ol_style_Style|Array} Style + */ + getStyle () { + const stroke = new Stroke({ + color : "#33b1ff", + width : 2, + }); + const fill = new Fill({ + color : "rgb(51, 177, 255, 0.2)" + }); + const image = this._image || new Icon({ + src : mapPinIcon, + color : "#000091", + anchor : [0.5, 1], + }); + const circle = new CircleStyle({ + stroke : new Stroke({ + color : "#fff", + width : 2 + }), + fill : new Fill({ + color : "#33b1ff", + }), + radius : 5, + }); + + switch (this._type) { + case "LineString": + case "Polygon": { + return [ + new Style({ + image : circle, + stroke : stroke, + fill : fill, + }), + new Style({ + image : circle, + geometry : (f) => new MultiPoint( getFlatCoordinates(f.getGeometry().getCoordinates() ) ), + }) + ]; + } + case "Point": + default: { + return new Style({ + image : image, + stroke : stroke, + fill : fill, + }); + } + } + } + + /** Set an image for drawing points + * @param {ImageStyle} image Image + */ + setImage (image) { + // TODO : allow to set custom image for point drawing + if (image instanceof ImageStyle) { + this._image = image; + } + } + + /** Get image used for drawing points + * @returns {ImageStyle} Image + */ + getImage () { + return this._image; + } + + /** + * Display info message + * @param {String} info message info + */ + showInfo (info = "") { + this.info.setInfo(info); + } + +} + + +export default DrawingInteraction; + +// Expose DrawingInteraction as ol.interaction.Drawing (for a build bundle) +if (window.ol && window.ol.interaction) { + window.ol.interaction.Drawing = DrawingInteraction; +} diff --git a/src/packages/Interactions/Selecting.js b/src/packages/Interactions/Selecting.js new file mode 100644 index 000000000..15f2c3194 --- /dev/null +++ b/src/packages/Interactions/Selecting.js @@ -0,0 +1,53 @@ +import Select from "ol/interaction/Select"; + +/** Outil de selection et d'interaction avec des features + * + */ +class SelectingInteraction extends Select { + + constructor (options) { + options = options || {}; + // Prevent selecting on empty space + options.filter = (feature, layer) => { + return (!!layer); + }; + // Handle double click on selected feature + options.condition = function (e) { + if (e.type === "dblclick") { + let found = false; + map.forEachFeatureAtPixel( + e.pixel, + (feature, layer) => { + if (this.getLayer(feature)) { + found = feature; + } + return (!found); + }, + { + layerFilter : this.layerFilter_, + hitTolerance : this.hitTolerance_, + }, + ); + if (found) { + e.feature = found; + this.dispatchEvent(e); + e.stopPropagation(); + } + } + return (e.type === "singleclick"); + return (e.type === "click"); + }; + + // call parent constructor + super(options); + } + +} + + +export default SelectingInteraction; + +// Expose SelectingInteraction as ol.interaction.Selecting (for a build bundle) +if (window.ol && window.ol.interaction) { + window.ol.interaction.Selecting = SelectingInteraction; +} diff --git a/src/packages/Services/AbstractSearchService.js b/src/packages/Services/AbstractSearchService.js new file mode 100644 index 000000000..247c3bfd6 --- /dev/null +++ b/src/packages/Services/AbstractSearchService.js @@ -0,0 +1,129 @@ +import Logger from "../Utils/LoggerByDefault"; +import BaseObject from "ol/Object"; + +var logger = Logger.getLogger("searchengine"); + +/** + * @classdesc + * Service de recherche abstrait : base commune pour les services d'autocomplétion et de géocodage. + * À surcharger pour chaque type de service (IGN, INSEE, etc.). + * + * @alias ol.service.AbstractSearchService + * @abstract + * @module SearchService + */ +class AbstractSearchService extends BaseObject { + + /** + * Constructeur du service abstrait. + * @constructor + * @param {AbstractSearchServiceOptions} options Options du service + */ + constructor (options) { + options = options || {}; + + // call ol.control.Control constructor + super(options); + + if ((this.constructor == AbstractSearchService)) { + throw new TypeError("AbstractSearchService cannot be instantiate"); + } + /** + * Nom de la classe (heritage) + * @private + */ + this.CLASSNAME = "AbstractSearchService"; + + // initialisation du composant + this.initialize(options); + + return this; + } + + /** + * Initialise le service avec les options fournies. + * @protected + * @param {AbstractSearchServiceOptions} options Options de configuration du service + */ + initialize (options) { + this.AUTOCOMPLETE_EVENT = "autocomplete"; + this.SEARCH_EVENT = "search"; + this.ERROR_EVENT = "error"; + + this._autocompleteLocations = []; + this._locations = []; + + if (options.autocomplete === false) { + this.set("autocomplete", false); + } + this.set("index", options.index || "address,poi"); + this.set("limit", typeof options.limit === "number" ? options.limit : 1); + this.set("returnTrueGeometry", !!options.returnTrueGeometry); + } + + /** + * Récupère la liste des résultats d'autocomplétion. + * @param {Number} [index] Index du résultat (optionnel). Si non fourni, retourne tous les résultats. + * @returns {Array|AutocompleteResult} Un tableau de résultats ou un résultat unique + */ + getAutocompleteLocations (index) { + if (index === undefined) { + return this._autocompleteLocations; + } else { + return this._autocompleteLocations[index]; + } + } + + /** + * Récupère la liste des résultats de recherche finale. + * @param {Number} [index] Index du résultat (optionnel). Si non fourni, retourne tous les résultats. + * @returns {Array|SearchResult} Un tableau de résultats ou un résultat unique + */ + getResult (index) { + if (index === undefined) { + return this._locations; + } else { + return this._locations[index]; + } + } + + /** + * Lance une autocomplétion. + * Méthode à surcharger dans les services concrets. + * @param {String} text Texte d'autocomplétion + * @abstract + */ + autocomplete (text) { } + + + /** + * Lance une recherche. + * Méthode à surcharger dans les services concrets. + * @param {Object} obj Options de recherche (dépend du service) + * @abstract + */ + search (obj) { } + + + /** + * Retourne le titre à afficher pour un résultat. + * Méthode à surcharger dans les services concrets. + * @param {AutocompleteResult} obj Objet dont le titre dérive + * @abstract + * @returns {String} Titre du résultat + */ + getItemTitle (obj) { + return obj; + } + +} + +export default AbstractSearchService; + +// Expose SearchEngine as ol.control.SearchEngine (for a build bundle) +if (window.ol) { + if (!window.ol.service) { + window.ol.service = {}; + } + window.ol.service.AbstractSearchService = AbstractSearchService; +} diff --git a/src/packages/Services/DefaultSearchService.js b/src/packages/Services/DefaultSearchService.js new file mode 100644 index 000000000..bad617675 --- /dev/null +++ b/src/packages/Services/DefaultSearchService.js @@ -0,0 +1,78 @@ +import Logger from "../Utils/LoggerByDefault"; +import AbstractSearchService from "./AbstractSearchService"; + +var logger = Logger.getLogger("searchengine"); + +/** + * @classdesc + * Service de recherche par défaut (implémentation simple pour tests ou données locales). + * + * @alias ol.service.DefaultSearchService + * @module SearchService + * @extends AbstractSearchService + */ +class DefaultSearchService extends AbstractSearchService { + + /** + * Constructeur du service par défaut. + * @constructor + * @param {AbstractSearchServiceOptions} options Options du service (ex: {searchTab: [...]}) + * @param {Array} options.searchTab Tableau d'élément dans lequel rechercher + */ + constructor (options) { + options = options || {}; + super(options); + /** + * Nom de la classe (héritage) + * @private + */ + this.CLASSNAME = "DefaultSearchService"; + if (options.searchTab) { + this._searchTab = options.searchTab || []; + } + } + + /** + * Lance une autocomplétion sur la liste locale. + * @override + * @param {String} value Valeur à compléter + */ + autocomplete (value) { + // Simulate asynchronous behavior + this._autocompleteLocations = []; + const rex = new RegExp(value, "i"); + (this._searchTab || []).forEach((city) => { + if (rex.test(city.toLowerCase())) { + this._autocompleteLocations.push(city); + } + }); + // When search is finished + this.dispatchEvent({ + type : this.AUTOCOMPLETE_EVENT, + result : this._autocompleteLocations + }); + } + + /** + * Lance une recherche (simulée). + * @override + * @param {Object} obj Options de recherche + */ + search (obj) { + this.dispatchEvent({ + type : this.SEARCH_EVENT, + result : obj + }); + } + +} + +export default DefaultSearchService; + +// Expose DefaultSearchService as ol.service.DefaultSearchService (for a build bundle) +if (window.ol) { + if (!window.ol.service) { + window.ol.service = {}; + } + window.ol.service.DefaultSearchService = DefaultSearchService; +} diff --git a/src/packages/Services/IGNSearchService.js b/src/packages/Services/IGNSearchService.js new file mode 100644 index 000000000..6c0f0a152 --- /dev/null +++ b/src/packages/Services/IGNSearchService.js @@ -0,0 +1,663 @@ +import GeoJSON from "ol/format/GeoJSON"; +import Logger from "../Utils/LoggerByDefault"; +import AbstractSearchService from "./AbstractSearchService"; +// import geoportal library access +import Gp from "geoportal-access-lib"; +// import local +import Utils from "../Utils/Helper"; +import GeocodeUtils from "../Utils/GeocodeUtils"; +// Service +import Search from "./Search"; +import Feature from "ol/Feature.js"; +import Point from "ol/geom/Point.js"; + +var logger = Logger.getLogger("searchengine"); + +// logger.log = () => { }; + +/** + * @classdesc + * Service de recherche IGN (utilise les services IGN / Search wrapper). + * + * @alias ol.service.IGNSearchService + * @module SearchService + * @extends AbstractSearchService + */ +class IGNSearchService extends AbstractSearchService { + + /** + * Constructeur du service IGN. + * @constructor + * @param {AbstractSearchServiceOptions} options Options du service IGN (clé API, index, etc.) + */ + constructor (options) { + options = options || {}; + + // call ol.control.Control constructor + super(options); + + if (!(this instanceof IGNSearchService)) { + throw new TypeError("ERROR CLASS_CONSTRUCTOR"); + } + /** + * Nom de la classe (heritage) + * @private + */ + this.CLASSNAME = "IGNSearchService"; + + return this; + } + + /** + * Initialise le service avec les options fournies. + * @protected + * @override + * @param {AbstractSearchServiceOptions} options Options de configuration du service + */ + initialize (options) { + super.initialize(options); + + // define default options + this.options = { + searchOptions : { + maximumEntries : 5, + serviceOptions : { + maximumResponses : 10, + }, + filterLayers : true + }, + geocodeOptions : { + serviceOptions : {} + }, + autocomplete : true, + autocompleteOptions : { + serviceOptions : { + maximumResponses : 10, + }, + triggerGeocode : false, + triggerDelay : 1000, + prettifyResults : true + }, + }; + + Utils.mergeParams(this.options, options); + + // configuration avec gestion des options surchargées du service + if (this.options.searchOptions) { + if (this.options.searchOptions.serviceOptions) { + if (this.options.searchOptions.serviceOptions.url) { + Search.setUrl(this.options.searchOptions.serviceOptions.url); + } + if (this.options.searchOptions.serviceOptions.fields) { + Search.setFields(this.options.searchOptions.serviceOptions.fields); + } + if (this.options.searchOptions.serviceOptions.index) { + Search.setIndex(this.options.searchOptions.serviceOptions.index); + } + if (this.options.searchOptions.serviceOptions.size) { + Search.setSize(this.options.searchOptions.serviceOptions.size); + } + if (this.options.searchOptions.serviceOptions.maximumResponses) { + Search.setMaximumResponses(this.options.searchOptions.serviceOptions.maximumResponses); + } + } + if (this.options.searchOptions.filterServices) { + Search.setFiltersByService(this.options.searchOptions.filterServices); + } + if (this.options.searchOptions.filterLayersPriority) { + Search.setFiltersByLayerPriority(this.options.searchOptions.filterLayersPriority); + } + if (this.options.searchOptions.filterWMTSPriority) { + Search.setFilterWMTSPriority(this.options.searchOptions.filterWMTSPriority); + } + if (this.options.searchOptions.filterTMS === false) { + Search.setFilterTMS(this.options.searchOptions.filterTMS); + } + if (this.options.searchOptions.filterProjections) { + Search.setFiltersByProjection(this.options.searchOptions.filterProjections); + } + } + // abonnement au service + Search.target.addEventListener("suggest", (e) => { + logger.debug(e); + let suggestResults = e.detail; + // filtre des suggestions selon la configuration ou l'option filterLayersList + suggestResults = this._filterResultsFromConfigLayers(suggestResults); + + this._fillSearchedSuggestListContainer(suggestResults); + }); + + /** + * Label du géocodage / de la recherche + * @type {String} + */ + this._currentGeocodingLocation; + /** + * Liste de résultats d'autocomplétion + * @type {Array} + */ + this._suggestedLocations; + } + + + /** + * Lance une autocomplétion via le service IGN. + * @override + * @param {String} value Texte à envoyer + */ + autocomplete (value) { + if (!value) { + return; + } + + // on sauvegarde le localisant + this._currentGeocodingLocation = value; + + // On effectue la requête au service d'autocompletion. + this._requestAutoComplete({ + text : value, + onSuccess : this._onSuccessAutoComplete.bind(this), + onFailure : this._onFailureAutoComplete.bind(this) + }); + } + + /** + * @override + * @param {AutocompleteResult} obj Objet dont le titre dérive + * @returns {String} Titre à afficher + */ + getItemTitle (obj) { + return obj.fullText; + } + + /** + * Exécute une requête d'autocomplétion auprès du service IGN. + * @private + * @param {Object} settings Paramètres de la requête (texte, callbacks, etc.) + * @param {String} settings.text Texte à compléter + * @param {Function} settings.onSuccess Callback en cas de succès + * @param {Function} settings.onFailure Callback en cas d'échec + */ + _requestAutoComplete (settings) { + // on ne fait pas de requête si on n'a pas renseigné de parametres ! + if (!settings || (typeof settings === "object" && Object.keys(settings).length === 0)) { + return; + } + + // on ne fait pas de requête si la parametre 'text' est vide ! + if (!settings.text) { + return; + } + + logger.log(settings); + + let options = {}; + // on recupere les options du service + Utils.assign(options, this.options.autocompleteOptions.serviceOptions); + // ainsi que la recherche et les callbacks + Utils.assign(options, settings); + + // cas où la clef API n'est pas renseignée dans les options du service, + // on utilise celle renseignée au niveau du controle ou la clé "calcul" par défaut. + options.apiKey = options.apiKey || this.options.apiKey; + + // si l'utilisateur a spécifié le paramètre ssl au niveau du control, on s'en sert + // true par défaut (https) + if (typeof options.ssl !== "boolean") { + if (typeof this.options.ssl === "boolean") { + options.ssl = this.options.ssl; + } else { + options.ssl = true; + } + } + logger.log(options); + + Gp.Services.autoComplete(options); + } + + /** + * Fonction appelée en cas de succès de l'autocomplétion. + * @param {Object} results Résultats de la requête. + * @param {Array} results.suggestedLocations Tableau de suggestions. + */ + _onSuccessAutoComplete (results) { + let _maximumEntries = this.options.autocompleteOptions.maximumEntries; + let _prettifyResults = this.options.autocompleteOptions.prettifyResults; + + logger.log("request from AutoComplete", results); + if (results) { + // on sauvegarde l'etat des résultats + this._suggestedLocations = results.suggestedLocations; + this._autocompleteLocations = []; + // on vérifie qu'on n'a pas récupéré des coordonnées nulles (par ex recherche par code postal) + for (let i = 0; i < this._suggestedLocations.length; i++) { + let ilocation = this._suggestedLocations[i]; + if (ilocation.position && ilocation.position.x === 0 && ilocation.position.y === 0 && ilocation.fullText) { + // si les coordonnées sont nulles, il faut relancer une requête de géocodage avec l'attribut "fullText" récupéré + this._getGeocodeCoordinatesFromFullText(ilocation, i); + } else { + // sinon on peut afficher normalement le résultat dans la liste + this._autocompleteLocations.push(ilocation); + } + }; + // on filtre et enjolive éventuellement les résultats + if (_prettifyResults === true) { + this._prettifyAutocompleteResults(this._autocompleteLocations); + } + // on ne garde que le nombre de résultats que l'on veut afficher + if (_maximumEntries) { + this._autocompleteLocations = this._autocompleteLocations.slice(0, _maximumEntries); + } + + // on annule eventuellement une requete de geocodage en cours car on obtient des + // de nouveau des resultats d'autocompletion... + if (this._triggerHandler) { + clearTimeout(this._triggerHandler); + this._triggerHandler = null; + logger.warn("Cancel a geocode request !"); + } + this.dispatchEvent({ + type : this.AUTOCOMPLETE_EVENT, + result : this._autocompleteLocations, + }); + } + } + + /** + * Fonction appelée en cas d'erreur sur le service d'autocomplétion + * @param {ErrorService} error Erreur renvoyée par le service + */ + _onFailureAutoComplete (error) { + let _triggerGeocode = this.options.autocompleteOptions.triggerGeocode; + let _triggerDelay = this.options.autocompleteOptions.triggerDelay; + + let onSuccess = function (results) { + logger.log("request from Geocoding", results); + if (results) { + this._clearResults(); + // on modifie la structure des reponses pour être + // compatible avec l'autocomplétion ! + let locations = results.locations; + for (let i = 0; i < locations.length; i++) { + let location = locations[i]; + location.fullText = GeocodeUtils.getGeocodedLocationFreeform(location); + location.position = { + x : location.position.lon, + y : location.position.lat + }; + this._autocompleteLocations.push(location); + } + } + this.dispatchEvent({ + type : this.AUTOCOMPLETE_EVENT, + result : this._autocompleteLocations, + }); + }; + + // FIXME + // où affiche t on les messages : ex. 'No suggestion matching the search' ? + this._clearResults(); + logger.log(error.message); + // on envoie une requete de geocodage si aucun resultat d'autocomplétion + // n'a été trouvé ! Et on n'oublie pas d'annuler celle qui est en cours ! + if (error.message === "No suggestion matching the search" && _triggerGeocode /* && value.length === 5 */) { + if (this._triggerHandler) { + clearTimeout(this._triggerHandler); + logger.warn("Cancel the last geocode request !"); + } + this._triggerHandler = setTimeout( + function () { + logger.warn("Launch a geocode request !"); + this._requestGeocoding({ + location : value, + // callback onSuccess + onSuccess : onSuccess.bind(this), + // callback onFailure + onFailure : function (error) { + logger.log(error.message); + } + }); + }, _triggerDelay + ); + } + } + + /** + * this method is called by this.onAutoCompleteSearchText() + * and it clears suggested location from duplicate entries and improve unprecise fulltext entries. + * + * @param {Array} autocompleteResults - Array of autocompleteResults to display + * @private + */ + _prettifyAutocompleteResults (autocompleteResults) { + for (let i = autocompleteResults.length - 1; i >= 0; i--) { + const autocompleteResult = autocompleteResults[i]; + if ((autocompleteResult.type === "StreetAddress" && autocompleteResult.kind === "municipality") || + autocompleteResult.type === "PositionOfInterest" && autocompleteResult.poiType[0] === "lieu-dit habité" && autocompleteResult.poiType[1] === "zone d'habitation") { + // on retire les éléments streetAdress - municipality car déjà pris en compte par POI + autocompleteResults.splice(i, 1); + } + // on précise le type dans le fulltext au POI des types département et région + if ((autocompleteResult.type === "PositionOfInterest" && autocompleteResult.poiType[0] === "administratif" && + (autocompleteResult.poiType[1] === "département" || autocompleteResult.poiType[1] === "région"))) { + autocompleteResult.fullText = autocompleteResult.fullText + ", " + autocompleteResult.poiType[1]; + } + }; + } + + + /** + * this method is called by Gp.Services.autoComplete callback in case of success + * (cf. this.onAutoCompleteSearchText), for suggested locations with null coordinates + * (case of postalCode research for instance). + * Send a geocode request with suggested location 'fullText' attribute, to get its coordinates and display it in autocomplete results list container. + * + * @param {Gp.Services.AutoCompleteResponse.SuggestedLocation} suggestedLocation - autocompletion result (with null coordinates) to be geocoded + * @param {Number} i - suggestedLocation position in Gp.Services.AutoCompleteResponse.suggestedLocations autocomplete results list + * @private + */ + _getGeocodeCoordinatesFromFullText (suggestedLocation, i) { + var context = this; + Gp.Services.geocode({ + apiKey : this.options.apiKey, + ssl : this.options.ssl, + q : GeocodeUtils.getSuggestedLocationFreeform(suggestedLocation), + index : suggestedLocation.type, + // callback onSuccess + onSuccess : function (response) { + logger.log("request from Geocoding (coordinates null)", response); + if (response.locations && response.locations.length !== 0 && response.locations[0].position) { + // on modifie les coordonnées du résultat en EPSG:4326 donc lat,lon + /// \TODO verifier si l'inversion des coordonnees est necessaire + if (context._suggestedLocations && context._suggestedLocations[i]) { + context._suggestedLocations[i].position = { + lon : response.locations[0].position.y, + lat : response.locations[0].position.x + }; + // et on l'affiche dans la liste + context._locationsToBeDisplayed.unshift(context._suggestedLocations[i]); + context._fillAutoCompletedLocationListContainer(context._locationsToBeDisplayed); + } + } + }, + // callback onFailure + onFailure : function () { + // si on n'a pas réussi à récupérer les coordonnées, on affiche quand même le résultat + if (context._suggestedLocations && context._suggestedLocations[i]) { + context._createAutoCompletedLocationElement(context._suggestedLocations[i], i); + } + } + }); + } + + _clearResults () { + this._autocompleteLocations = []; + this._locations = []; + } + + /** + * Lance une recherche sur les services de géocodage de l'IGN + * @see {@link https://data.geopf.fr/geocodage/search} + * @see {@link https://data.geopf.fr/geocodage/openapi} + * @param {IGNSearchObject} object Recherche + * @abstract + */ + search (object) { + const location = object.location; + const filters = object.filters ? object.filters : {}; + + if (location === undefined) { + return; + } + // on ajoute le texte de l'autocomplétion dans l'input + let label; + let index = this.get("index"); + let truegeometry = this.get("returnTrueGeometry"); + if (typeof location === "string") { + // Location est un texte, on prend les valeurs par défaut + label = location; + } else { + // location est un objet : on vérifie les informations qu'il comporte + + // Récupère les infos (s'il y'en a) + index = location.type ? location.type : index; + label = GeocodeUtils.getSuggestedLocationFreeform(location); + // TODO : AMÉLIORER CETTE PARTIE (REDONDANTE) + // Enlève l'ajout du prettify + if ((location.type === "PositionOfInterest" && location.poiType[0] === "administratif" && + (location.poiType[1] === "département" || location.poiType[1] === "région"))) { + label = label.substring(0, label.length - (location.poiType[1].length + 2)); + } + + if (index === "PositionOfInterest") { + // Recherche d'un POI : ajout d'infos supplémentaires + let poiType = location.poiType[0]; + if (poiType === "administratif" && location.poiType[1]) { + poiType = location.poiType[1]; + } + filters.category = poiType ? poiType : null; + + // Retourne la géométrie pour certains types seulement + truegeometry = false; + const trueGeometries = ["administratif", "département", "construction", "hydrographie"]; + for (let i = 0; i < location.poiType.length; i++) { + const type = location.poiType[i]; + if (type !== "lieu-dit habité" && trueGeometries.includes(type)) { + truegeometry = true; + break; + } + } + } + filters.postalCode = location.postalCode ? location.postalCode : null; + + // Retire chaque valeurs nulles + for (const key in filters) { + if (!Object.hasOwn(filters, key)) { + continue; + }; + if (filters[key] === null) { + delete filters[key]; + } + } + } + + // on centre la vue et positionne le marker, à la position reprojetée dans la projection de la carte + + this._requestGeocoding({ + index : index, + limit : this.get("limit"), + returnTrueGeometry : truegeometry, + location : label, + filters : filters, + onSuccess : this._onSuccessSearch.bind(this), + onFailure : this._onFailureSearch.bind(this, location), + }); + // on sauvegarde le localisant + this._currentGeocodingLocation = label; + } + + + /** + * this method is called by this.onAutoCompleteSearch() + * and executes a request to the service. + * + * @param {Object} settings - service settings + * @param {String} settings.location - text + * @param {Function} settings.onSuccess - callback + * @param {Function} settings.onFailure - callback + * @private + */ + _requestGeocoding (settings) { + // on ne fait pas de requête si on n'a pas renseigné de parametres ! + if (!settings || (typeof settings === "object" && Object.keys(settings).length === 0)) { + return; + } + + logger.log(settings); + + var options = {}; + // on recupere les options du service + Utils.assign(options, this.options.geocodeOptions.serviceOptions); + // ainsi que la recherche et les callbacks + Utils.assign(options, settings); + options.maximumResponses = settings.limit; + // on redefinie les callbacks si les callbacks de service existent + var bOnSuccess = !!(this.options.geocodeOptions.serviceOptions.onSuccess !== null && typeof this.options.geocodeOptions.serviceOptions.onSuccess === "function"); + if (bOnSuccess) { + var cbOnSuccess = function (e) { + settings.onSuccess.bind(this, e); + this.options.geocodeOptions.serviceOptions.onSuccess.bind(this, e); + }; + options.onSuccess = cbOnSuccess.bind(this); + } + + var bOnFailure = !!(this.options.geocodeOptions.serviceOptions.onFailure !== null && typeof this.options.geocodeOptions.serviceOptions.onFailure === "function"); + if (bOnFailure) { + var cbOnFailure = function (e) { + settings.onFailure.bind(this, e); + this.options.geocodeOptions.serviceOptions.onFailure.bind(this, e); + }; + options.onFailure = cbOnFailure.bind(this); + } + + // cas où la clef API n'est pas renseignée dans les options du service, + // on utilise celle renseignée au niveau du controle ou la clé "calcul" par défaut + options.apiKey = options.apiKey || this.options.apiKey; + + // si l'utilisateur a spécifié le paramètre ssl au niveau du control, on s'en sert + // true par défaut (https) + if (typeof options.ssl !== "boolean") { + if (typeof this.options.ssl === "boolean") { + options.ssl = this.options.ssl; + } else { + options.ssl = true; + } + } + + logger.log(options); + + Gp.Services.geocode(options); + } + + /** Get features based on current search result + * @param {Number} index Index of the result + * @returns {Object} Object containing feature and extent (if any) + */ + getResultFeatures (index) { + let location = this.getResult(index); + if (!location) { + return { featureFilter : null, extent : null }; + } + let position = [ + location.position.lon, + location.position.lat + ]; + let f, extent; + if (location.placeAttributes.truegeometry) { + let geom = location.placeAttributes.truegeometry; + if (typeof geom === "string") { + geom = JSON.parse(geom); + } + + let format = new GeoJSON(); + let geometry = format.readGeometry(geom, { + dataProjection : "EPSG:4326", // incoming data + featureProjection : "EPSG:3857" // map projection + }); + if (geom.type !== "Point" && geom.type !== "MultiPoint") { + extent = new Feature({ geometry : geometry }); + // Point au milieu + const geom = new Point(position); + geom.transform("EPSG:4326", "EPSG:3857"); + f = new Feature({ geometry : geom }); + } else { + extent = null; + f = new Feature({ geometry : geometry }); + } + } else { + const geom = new Point(position); + geom.transform("EPSG:4326", "EPSG:3857"); + f = new Feature({ geometry : geom }); + } + + if (extent) { + extent.set("infoPopup", this._currentGeocodingLocation); + } + f.set("infoPopup", this._currentGeocodingLocation); + return { feature : f, extent : extent }; + } + + /** + * Fonction appelée en cas de succès du géocodage + * + * @param {Object} results Résultats de la recherche + * @private + */ + _onSuccessSearch (results) { + this._locations = results.locations; + + const features = this.getResultFeatures(0); + + /** + * event triggered when an element of the results is clicked for autocompletion + * + * @event searchengine:autocomplete:click + * @property {Object} type - event + * @property {Object} feature - feature renvoyé par le géocodage + * @property {Object} target - instance SearchEngine + * @example + * SearchEngine.on("searchengine:autocomplete:click", function (e) { + * console.log(e.location); + * }) + */ + this.dispatchEvent({ + type : this.SEARCH_EVENT, + result : features.feature, + extent : features.extent, + nbResults : results.locations.length, + }); + } + + /** + * Fonction appelée en cas d'erreur renvoyée par le service de géocodage. + * @param {AutocompleteResult} location Localisation de l'autocomplétion. + * @param {ErrorService} error Erreur renvoyée par le service. + */ + _onFailureSearch (location, error) { + logger.warn(error); + if (!location || !location.position) { + this.dispatchEvent({ + type : this.ERROR_EVENT, + location : location, + error : error + }); + return; + } + + let position = [ + location.position.x, + location.position.y + ]; + + // Créé le point + const geom = new Point(position); + let f = new Feature({ geometry : geom }); + f.set("infoPopup", this._currentGeocodingLocation); + + this.dispatchEvent({ + type : this.SEARCH_EVENT, + result : f + }); + } + +} + +export default IGNSearchService; + +// Expose IGNSearchService as ol.service.IGNSearchService (for a build bundle) +if (window.ol) { + if (!window.ol.service) { + window.ol.service = {}; + } + window.ol.service.IGNSearchService = IGNSearchService; +} diff --git a/src/packages/Services/InseeSearchService.js b/src/packages/Services/InseeSearchService.js new file mode 100644 index 000000000..f9ed2a0ab --- /dev/null +++ b/src/packages/Services/InseeSearchService.js @@ -0,0 +1,157 @@ +import Logger from "../Utils/LoggerByDefault"; +import AbstractSearchService from "./AbstractSearchService"; +import IGNSearchService from "./IGNSearchService"; + +var logger = Logger.getLogger("searchengine"); + + +/** + * @classdesc + * Service de recherche pour les codes INSEE : interroge l'API geo.api.gouv.fr puis relaie vers IGNSearchService. + * @see {@link https://geo.api.gouv.fr/} + * + * @alias ol.service.InseeSearchService + * @module SearchService + */ +class InseeSearchService extends AbstractSearchService { + + /** + * Constructeur du service INSEE. + * @constructor + * @param {AbstractSearchServiceOptions} options Options du service INSEE + */ + constructor (options) { + options = options || {}; + // Aucune autocomplétion + options.autocomplete = false; + super(options); + /** + * Nom de la classe (heritage) + * @private + */ + this.CLASSNAME = "InseeSearchService"; + + /** + * Service IGN utilisé pour relayer la recherche. + * @type {IGNSearchService} + * @private + */ + this.ignService = new IGNSearchService({ + autocomplete : false, + returnTrueGeometry : true, + index : "poi", + }); + + this.ignService.on(this.SEARCH_EVENT, this._onSearch.bind(this)); + } + + /** + * Désactive l'autocomplétion (non disponible pour INSEE). + * @override + */ + autocomplete () { + return; + } + + /** + * Lance une recherche par code INSEE. + * @override + * @param {Object} object Objet contenant le code INSEE + * @param {String} object.location Code INSEE à rechercher + * @param {Boolean} [arr] Si vrai, recherche pour un arrondissement + */ + search (object, arr) { + const insee = object.location; + const response = this._requestGeoAPI({ value : insee, arr : !!arr }); + response.then(r => { + if (r instanceof Array && r.length) { + const result = r[0]; + + let location = result.nom; + // Sinon la requête ne se lancera pas + if (result.nom.length < 3) { + location = `${result.nom}, ${result.codesPostaux[0]}`; + } + + let filters = { + category : "administratif", + citycode : result.code + }; + + const obj = { + location : location, + filters : filters, + }; + + this.ignService.search(obj); + } else { + // Cherche, pour les département 13, 69 et 75 si c'est un arrondissement + if (/^(13|69|75)/.test(insee) && !arr) { + this.search(object, true); + return; + } + // Pas de résultat, on envoi un événement "search" vide + this._onSearch({ + type : this.SEARCH_EVENT, + }); + } + }); + } + + /** + * Relaye l'événement de recherche du service IGN. + * @private + * @param {Event} e Événement de recherche + */ + _onSearch (e) { + this.dispatchEvent(e); + } + + /** + * Interroge l'API geo.api.gouv.fr pour obtenir les informations du code INSEE. + * @see {@link https://geo.api.gouv.fr/} + * @private + * @param {Object} settings Paramètres de requêtes + * @param {String} settings.value Code INSEE à interroger + * @param {Boolean} [settings.arr] Si vrai, recherche sur un arrondissement + * @returns {Promise} Résultat de l'API + */ + async _requestGeoAPI (settings) { + const baseURL = "https://geo.api.gouv.fr/communes"; + const format = "json"; + const fields = ["nom", "code", "codesPostaux"]; + const arr = settings.arr; + let url = `${baseURL}?code=${settings.value}&format=${format}&fields=${fields}`; + + if (arr) { + url += "&type=arrondissement-municipal"; + } + + try { + const response = await fetch(url, { + headers : { + "Content-Type" : "application/json", + }, + }); + if (!response.ok) { + throw new Error(`Response status: ${response.status}`); + } + + const result = await response.json(); + return result; + } catch (error) { + console.error(error.message); + } + } + +} + +export default InseeSearchService; + +// Expose InseeSearchService as ol.service.InseeSearchService (for a build bundle) +if (window.ol) { + if (!window.ol.service) { + window.ol.service = {}; + } + window.ol.service.InseeSearchService = InseeSearchService; +} diff --git a/src/packages/Services/typedefs.js b/src/packages/Services/typedefs.js new file mode 100644 index 000000000..b171ee9c2 --- /dev/null +++ b/src/packages/Services/typedefs.js @@ -0,0 +1,118 @@ +/** + * Typedefs partagés pour les Services SearchService. + * Ce fichier centralise les types réutilisables afin que typedoc génère des docs cohérentes. + */ + +/** + * Options de construction d'un service de recherche. + * @typedef {Object} AbstractSearchServiceOptions + * @property {String} [apiKey] - Clé API pour les services IGN. + * @property {Boolean} [ssl=true] - Forcer HTTPS. + * @property {AutocompleteOptions} [autocompleteOptions] - Options de l'autocomplétion. + * @property {SearchOptions} [searchOptions] - Options de la recherche finale. + * @property {GeocodeOptions} [geocodeOptions] - Options de géocodage. + * @property {Boolean} [autocomplete=true] + * @property {String} [index="address,poi"] + * @property {Number} [limit=1] + * @property {Boolean} [returnTrueGeometry=false] + */ + +/** + * Options pour l'autocomplétion. + * @typedef {Object} AutocompleteOptions + * @property {Object} [serviceOptions] - Options passées à Gp.Services.autoComplete + * @property {Number} [maximumResponses] - Nombre maximal de réponses retournées + * @property {Boolean} [triggerGeocode=false] - Si vrai, déclenche une requête de géocodage lorsque l'autocomplétion échoue + * @property {Number} [triggerDelay=1000] - Délai (ms) avant déclenchement du géocodage automatique + * @property {Boolean} [prettifyResults=false] - Si vrai, embellit/filtre les résultats + */ + + +/** + * Résultat d'une autocomplétion (voir {@link https://ignf.github.io/geoportal-access-lib/latest/jsdoc/Gp.Services.AutoComplete.SuggestedLocation.html}) + * @typedef {Object} AutocompleteResult + * @property {"StreetAddress"|"PositionOfInterest"} type - Type de suggestion. + * @property {Position} position - Coordonnées du point, dans le système de coordonnées spécifiées. + * @property {String} commune - Nom de la commune. + * @property {String} fullText - Texte complet représentant la suggestion. + * @property {String} postalCode - Code postal de la suggestion. + * @property {Number} classification - Nombre utilisé pour classigier l'importance de l'endroit suggéré de 1 (plus important) à 7 (moins important) + * @property {Array} [poiType] - Types POI détaillés. + * @property {String} [street] - Nom de la rue (types "StreetAddress" seulement). + * @property {String} [kind] - Nature du point d'intérêt, e.g. "prefecture", "municipality"... (types "PositionOfInterest" seulement). + */ + + +/** + * Objet envoyé pour le géocodage + * @typedef {Object} IGNSearchObject + * @property {AutocompleteResult|String} location - Objet utilisé pour faire la requête. + * Peut être soit le résultat de l'autocomplétion ou une chaîne de caractère. + * @property {IGNSearchFilter} [filters] - Filtres optionnels pour la recherche + */ + +/** + * Filtres optionnels pour le géocodage. + * Les filtres dépendent du type de recherche : adresses, toponymes ou parcelles cadastrales + * @typedef {Object} IGNSearchFilter + * @property {string} [postalCode] - Code postal (adresses, toponymes). + * @property {string} [inseeCode] - Code INSEE (adresses, toponymes). + * @property {string} [city] - Nom de la ville (adresses, toponymes). + * @property {string} [type] - Type de toponyme (toponymes uniquement). + * @property {string} [codeDepartement] - Code département (parcelles cadastrales uniquement). + * @property {string} [codeCommune] - Code commune (parcelles cadastrales uniquement). + * @property {string} [nomCommune] - Nom de la commune (parcelles cadastrales uniquement). + * @property {string} [codeCommuneAbs] - Code commune abs (parcelles cadastrales uniquement). + * @property {string} [codeArrondissement] - Code arrondissement (parcelles cadastrales uniquement). + * @property {string} [section] - Section cadastrale (parcelles cadastrales uniquement). + * @property {string} [numero] - Numéro de parcelle (parcelles cadastrales uniquement). + * @property {string} [feuille] - Feuille cadastrale (parcelles cadastrales uniquement). + */ + + + +/** + * Position dans un système de coordonnées. + * @typedef {Object} Position + * @property {Number} x - Longitude. + * @property {Number} y - Latitude. + */ + + +/** + * Erreur du service (voir {@link https://ignf.github.io/geoportal-access-lib/latest/jsdoc/Gp.Error.html}) + * @typedef {Object} ErrorService + * @property {string} message - Message d'erreur retourné par le service. + * @property {number} status - Code de statut (-1 si inconnu). + * @property {string} type - Type d'erreur (ex. "UNKNOWN_ERROR"). + * @property {string} name - Nom de l'erreur (ex. "ErrorService"). + * @property {string} [stack] - Stack trace de l'erreur, si disponible. + */ + +/** + * Options pour la recherche finale (géocodage). + * @typedef {Object} SearchOptions + * @property {Object} [serviceOptions] - Options passées à Gp.Services.geocode. + * @property {Number} [maximumResponses] - Nombre maximal de réponses. + * @property {Boolean} [filterLayers] - Active le filtrage des résultats par couche. + * @property {String|Array} [index] - Indexs utilisés (ex. "address,poi"). + * @property {Number} [limit] - Limite de résultats. + */ + +/** + * Options pour le géocodage manuel. + * @typedef {Object} GeocodeOptions + * @property {Object} [serviceOptions] - Options passées à Gp.Services.geocode. + * @property {String} [location] - Texte à géocoder. + * @property {Function} [onSuccess] - Callback en cas de succès. + * @property {Function} [onFailure] - Callback en cas d'échec. + */ + +/** + * Résultat d'une recherche (géocodage final). + * @typedef {Object} SearchResult + * @property {import("ol/Feature").default} feature - Feature OL contenant la géométrie. + * @property {import("ol/Feature").default|undefined} [extent] - Étendue si zone géographique. + * @property {String} [infoPopup] - Texte à afficher dans un popup. + */ + diff --git a/src/packages/Utils/Helper.js b/src/packages/Utils/Helper.js index 067b49a82..8c4504c81 100644 --- a/src/packages/Utils/Helper.js +++ b/src/packages/Utils/Helper.js @@ -1,3 +1,5 @@ +let uidCounter_ = 0; + /** * @module Helper * @alias module:~utils/HelperUtils @@ -99,6 +101,24 @@ var Helper = { } } } + }, + /** + * Gets a unique ID for an object. This mutates the object so that further calls + * with the same object as a parameter returns the same value. Unique IDs are generated + * as a strictly increasing sequence. Adapted from goog.getUid. + * + * @param {String} prefix - prefix for the unique ID + * @param {Object} [obj] The object to get the unique ID for. + * @return {String} The unique ID for the object. + * @api + */ + getUid : function (prefix, obj) { + if (obj) { + if (obj.id) { + return obj.id; + } + } + return prefix + (++uidCounter_); } }; diff --git a/src/packages/bundle.js b/src/packages/bundle.js index 5e0524109..f9dfab380 100644 --- a/src/packages/bundle.js +++ b/src/packages/bundle.js @@ -79,6 +79,7 @@ import LayerMapBox from "./Layers/LayerMapBox"; import LayerSwitcher from "./Controls/LayerSwitcher/LayerSwitcher"; import GetFeatureInfo from "./Controls/GetFeatureInfo/GetFeatureInfo"; import SearchEngine from "./Controls/SearchEngine/SearchEngine"; +import SearchEngineBase from "./Controls/SearchEngine/SearchEngineBase"; import MousePosition from "./Controls/MousePosition/MousePosition"; import Drawing from "./Controls/Drawing/Drawing"; import Route from "./Controls/Route/Route"; @@ -268,6 +269,7 @@ Ol.control.LayerSwitcher = LayerSwitcher; Ol.control.GeoportalAttribution = GeoportalAttribution; Ol.control.GetFeatureInfo = GetFeatureInfo; Ol.control.SearchEngine = SearchEngine; +Ol.control.SearchEngineBase = SearchEngineBase; Ol.control.Route = Route; Ol.control.Isocurve = Isocurve; Ol.control.GeoportalMousePosition = MousePosition; @@ -298,6 +300,7 @@ export { * @see ol.control.GeoportalAttribution * @see ol.control.GetFeatureInfo * @see ol.control.SearchEngine + * @see ol.control.SearchEngineBase * @see ol.control.Route * @see ol.control.Isocurve * @see ol.control.GeoportalMousePosition