feat: 自定义地图设置

This commit is contained in:
fit2cloud-chenyw 2023-11-24 17:19:55 +08:00
parent 831a92b91c
commit 60b0f322e7
17 changed files with 347 additions and 41 deletions

View File

@ -21,12 +21,16 @@ public class DeMvcConfig implements WebMvcConfigurer {
public void addResourceHandlers(ResourceHandlerRegistry registry) { public void addResourceHandlers(ResourceHandlerRegistry registry) {
String workDir = FILE_PROTOCOL + ensureSuffix(WORK_DIR, FILE_SEPARATOR); String workDir = FILE_PROTOCOL + ensureSuffix(WORK_DIR, FILE_SEPARATOR);
String uploadUrlPattern = ensureBoth(URL_SEPARATOR + UPLOAD_URL_PREFIX, AuthConstant.DE_API_PREFIX, URL_SEPARATOR) + "**"; String uploadUrlPattern = ensureBoth(URL_SEPARATOR + UPLOAD_URL_PREFIX, AuthConstant.DE_API_PREFIX, URL_SEPARATOR) + "**";
registry.addResourceHandler(uploadUrlPattern) registry.addResourceHandler(uploadUrlPattern).addResourceLocations(workDir);
.addResourceLocations(workDir);
// map // map
String mapDir = FILE_PROTOCOL + ensureSuffix(MAP_DIR, FILE_SEPARATOR); String mapDir = FILE_PROTOCOL + ensureSuffix(MAP_DIR, FILE_SEPARATOR);
String mapUrlPattern = ensureBoth(MAP_URL, AuthConstant.DE_API_PREFIX, URL_SEPARATOR) + "**"; String mapUrlPattern = ensureBoth(MAP_URL, AuthConstant.DE_API_PREFIX, URL_SEPARATOR) + "**";
registry.addResourceHandler(mapUrlPattern) registry.addResourceHandler(mapUrlPattern).addResourceLocations(mapDir);
.addResourceLocations(mapDir);
String geoDir = FILE_PROTOCOL + ensureSuffix(CUSTOM_MAP_DIR, FILE_SEPARATOR);
String geoUrlPattern = ensureBoth(GEO_URL, AuthConstant.DE_API_PREFIX, URL_SEPARATOR) + "**";
registry.addResourceHandler(geoUrlPattern).addResourceLocations(geoDir);
} }
} }

View File

@ -0,0 +1,13 @@
package io.dataease.map.bo;
import io.dataease.map.dao.auto.entity.Area;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
@EqualsAndHashCode(callSuper = true)
@Data
public class AreaBO extends Area implements Serializable {
private boolean custom = false;
}

View File

@ -0,0 +1,15 @@
package io.dataease.map.dao.ext.entity;
import lombok.Data;
import java.io.Serializable;
@Data
public class CoreAreaCustom implements Serializable {
private String id;
private String pid;
private String name;
}

View File

@ -0,0 +1,9 @@
package io.dataease.map.dao.ext.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.dataease.map.dao.ext.entity.CoreAreaCustom;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface CoreAreaCustomMapper extends BaseMapper<CoreAreaCustom> {
}

View File

@ -1,17 +1,34 @@
package io.dataease.map.manage; package io.dataease.map.manage;
import cn.hutool.core.collection.CollectionUtil; import io.dataease.api.map.dto.GeometryNodeCreator;
import io.dataease.api.map.vo.AreaNode; import io.dataease.api.map.vo.AreaNode;
import io.dataease.constant.StaticResourceConstants;
import io.dataease.exception.DEException;
import io.dataease.map.bo.AreaBO;
import io.dataease.map.dao.auto.entity.Area; import io.dataease.map.dao.auto.entity.Area;
import io.dataease.map.dao.auto.mapper.AreaMapper; import io.dataease.map.dao.auto.mapper.AreaMapper;
import io.dataease.map.dao.ext.entity.CoreAreaCustom;
import io.dataease.map.dao.ext.mapper.CoreAreaCustomMapper;
import io.dataease.utils.BeanUtils; import io.dataease.utils.BeanUtils;
import io.dataease.utils.CommonBeanFactory;
import io.dataease.utils.LogUtil;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable; import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import static io.dataease.constant.CacheConstant.CommonCacheConstant.WORLD_MAP_CACHE; import static io.dataease.constant.CacheConstant.CommonCacheConstant.WORLD_MAP_CACHE;
@ -19,6 +36,8 @@ import static io.dataease.constant.CacheConstant.CommonCacheConstant.WORLD_MAP_C
public class MapManage { public class MapManage {
private final static AreaNode WORLD; private final static AreaNode WORLD;
private static final String GEO_PREFIX = "geo_";
static { static {
WORLD = AreaNode.builder() WORLD = AreaNode.builder()
.id("000") .id("000")
@ -30,13 +49,34 @@ public class MapManage {
@Resource @Resource
private AreaMapper areaMapper; private AreaMapper areaMapper;
@Resource
private CoreAreaCustomMapper coreAreaCustomMapper;
public List<Area> defaultArea() {
return areaMapper.selectList(null);
}
private MapManage proxy() {
return CommonBeanFactory.getBean(MapManage.class);
}
@Cacheable(value = WORLD_MAP_CACHE, key = "'world_map'") @Cacheable(value = WORLD_MAP_CACHE, key = "'world_map'")
public AreaNode getWorldTree() { public AreaNode getWorldTree() {
List<Area> areas = areaMapper.selectList(null); List<Area> areas = proxy().defaultArea();
List<AreaBO> areaBOS = areas.stream().map(item -> BeanUtils.copyBean(new AreaBO(), item)).collect(Collectors.toList());
List<CoreAreaCustom> coreAreaCustoms = coreAreaCustomMapper.selectList(null);
if (CollectionUtils.isNotEmpty(coreAreaCustoms)) {
List<AreaBO> customBoList = coreAreaCustoms.stream().map(item -> {
AreaBO areaBO = BeanUtils.copyBean(new AreaBO(), item);
areaBO.setCustom(true);
return areaBO;
}).toList();
areaBOS.addAll(customBoList);
}
WORLD.setChildren(new ArrayList<>()); WORLD.setChildren(new ArrayList<>());
var areaNodeMap = new HashMap<String, AreaNode>(); var areaNodeMap = new HashMap<String, AreaNode>();
areaNodeMap.put(WORLD.getId(), WORLD); areaNodeMap.put(WORLD.getId(), WORLD);
areas.forEach(area -> { areaBOS.forEach(area -> {
var node = areaNodeMap.get(area.getId()); var node = areaNodeMap.get(area.getId());
if (node == null) { if (node == null) {
node = AreaNode.builder().build(); node = AreaNode.builder().build();
@ -64,5 +104,80 @@ public class MapManage {
return WORLD; return WORLD;
} }
@CacheEvict(cacheNames = WORLD_MAP_CACHE, key = "'world_map'")
@Transactional
public void saveMapGeo(GeometryNodeCreator request, MultipartFile file) {
List<Area> areas = proxy().defaultArea();
String code = getBusiGeoCode(request.getCode());
AtomicReference<String> atomicReference = new AtomicReference<>();
if (areas.stream().anyMatch(area -> {
boolean exist = area.getId().equals(code);
if (exist) {
atomicReference.set(area.getName());
}
return exist;
})) {
DEException.throwException(String.format("Area code [%s] is already exists for [%s]", code, atomicReference.get()));
}
CoreAreaCustom originData = null;
if (ObjectUtils.isNotEmpty(originData = coreAreaCustomMapper.selectById(getDaoGeoCode(code)))) {
DEException.throwException(String.format("Area code [%s] is already exists for [%s]", code, originData.getName()));
}
CoreAreaCustom coreAreaCustom = new CoreAreaCustom();
coreAreaCustom.setId(getDaoGeoCode(code));
coreAreaCustom.setPid(request.getPid());
coreAreaCustom.setName(request.getName());
coreAreaCustomMapper.insert(coreAreaCustom);
File geoFile = buildGeoFile(code);
try {
file.transferTo(geoFile);
} catch (IOException e) {
LogUtil.error(e.getMessage());
DEException.throwException(e);
}
}
@CacheEvict(cacheNames = WORLD_MAP_CACHE, key = "'world_map'")
@Transactional
public void deleteGeo(String code) {
if (!StringUtils.startsWith(code, GEO_PREFIX)) {
DEException.throwException("内置Geometry禁止删除");
}
coreAreaCustomMapper.deleteById(code);
File file = buildGeoFile(code);
if (file.exists()) {
file.delete();
}
}
private String getDaoGeoCode(String code) {
return StringUtils.startsWith(code, GEO_PREFIX) ? code : (GEO_PREFIX + code);
}
private String getBusiGeoCode(String code) {
return StringUtils.startsWith(code, GEO_PREFIX) ? code.substring(GEO_PREFIX.length()) : code;
}
private File buildGeoFile(String code) {
String id = getBusiGeoCode(code);
String customMapDir = StaticResourceConstants.CUSTOM_MAP_DIR;
String countryCode = countryCode(id);
String fileDirPath = customMapDir + "/" + countryCode + "/";
File dir = new File(fileDirPath);
if (!dir.exists()) {
dir.mkdirs();
}
String filePath = fileDirPath + id + ".json";
return new File(filePath);
}
private String countryCode(String code) {
return code.substring(0, 3);
}
} }

View File

@ -0,0 +1,26 @@
package io.dataease.map.server;
import io.dataease.api.map.GeoApi;
import io.dataease.api.map.dto.GeometryNodeCreator;
import io.dataease.map.manage.MapManage;
import jakarta.annotation.Resource;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/geometry")
public class GeoServer implements GeoApi {
@Resource
private MapManage mapManage;
@Override
public void saveMapGeo(GeometryNodeCreator request, MultipartFile file) {
mapManage.saveMapGeo(request, file);
}
@Override
public void deleteGeo(String id) {
mapManage.deleteGeo(id);
}
}

View File

@ -26,12 +26,25 @@ VALUES (20, 15, 2, 'template-setting', 'system/template-setting', 4, 'icon_templ
COMMIT; COMMIT;
DROP TABLE IF EXISTS `visualization_template_extend_data`; DROP TABLE IF EXISTS `visualization_template_extend_data`;
CREATE TABLE `visualization_template_extend_data` ( CREATE TABLE `visualization_template_extend_data`
`id` bigint NOT NULL, (
`dv_id` bigint DEFAULT NULL, `id` bigint NOT NULL,
`view_id` bigint DEFAULT NULL, `dv_id` bigint DEFAULT NULL,
`view_details` longtext, `view_id` bigint DEFAULT NULL,
`copy_from` varchar(255) DEFAULT NULL, `view_details` longtext,
`copy_id` varchar(255) DEFAULT NULL, `copy_from` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`) `copy_id` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
);
-- ----------------------------
-- Table structure for core_area_custom
-- ----------------------------
DROP TABLE IF EXISTS `core_area_custom`;
CREATE TABLE `core_area_custom`
(
`id` varchar(255) NOT NULL,
`name` varchar(255) NOT NULL,
`pid` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
); );

View File

@ -5,9 +5,21 @@ export const getWorldTree = (): Promise<IResponse<AreaNode>> => {
return request.get({ url: '/map/worldTree' }) return request.get({ url: '/map/worldTree' })
} }
export const getGeoJson = ( export const getGeoJson = (areaId: string): Promise<IResponse<FeatureCollection>> => {
country: string, let prefix = '/map'
areaId: string let areaCode = areaId
): Promise<IResponse<FeatureCollection>> => { if (isCustomGeo(areaId)) {
return request.get({ url: `/map/${country}/${areaId}.json` }) prefix = '/geo'
areaCode = getBusiGeoCode(areaId)
}
const realCountry = areaCode.substring(0, 3)
const url = `${prefix}/${realCountry}/${areaCode}.json`
return request.get({ url })
}
const isCustomGeo = (id: string) => {
return id.startsWith('geo_')
}
const getBusiGeoCode = (id: string) => {
return id.substring(4)
} }

View File

@ -130,7 +130,7 @@ service.interceptors.response.use(
return response return response
} else if (response.data.code === result_code || response.data.code === 50002) { } else if (response.data.code === result_code || response.data.code === 50002) {
return response.data return response.data
} else if (response.config.url.match(/^\/map\/\d{3}\/\d+\.json$/)) { } else if (response.config.url.match(/^\/map|geo\/\d{3}\/\d+\.json$/)) {
// TODO 处理静态文件 // TODO 处理静态文件
return response return response
} else { } else {

View File

@ -421,10 +421,8 @@ export const getGeoJsonFile = async (areaId: string): Promise<FeatureCollection>
const mapStore = useMapStoreWithOut() const mapStore = useMapStoreWithOut()
let geoJson = mapStore.mapCache[areaId] let geoJson = mapStore.mapCache[areaId]
if (!geoJson) { if (!geoJson) {
const country = areaId.slice(0, 3) const res = await getGeoJson(areaId)
geoJson = await getGeoJson(country, areaId).then(result => { geoJson = res.data
return result.data
})
mapStore.setMap({ id: areaId, geoJson }) mapStore.setMap({ id: areaId, geoJson })
} }
return toRaw(geoJson) return toRaw(geoJson)

View File

@ -43,6 +43,19 @@
:title="data.name" :title="data.name"
v-html="data.colorName && keyword ? data.colorName : data.name" v-html="data.colorName && keyword ? data.colorName : data.name"
/> />
<span class="geo-operate-container">
<el-tooltip
v-if="data.custom"
class="box-item"
effect="dark"
:content="t('common.delete')"
placement="top"
>
<el-icon @click.stop="delHandler(data)" class="hover-icon">
<Icon name="icon_delete-trash_outlined"></Icon>
</el-icon>
</el-tooltip>
</span>
</span> </span>
</template> </template>
</el-tree> </el-tree>
@ -83,7 +96,7 @@
</div> </div>
</el-main> </el-main>
</el-container> </el-container>
<geometry-edit ref="editor" :tree-data="treeData" @saved="loadTreeData" /> <geometry-edit ref="editor" @saved="loadTreeData(false)" />
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -95,6 +108,10 @@ import { getGeoJsonFile } from '@/views/chart/components/js/util'
import { cloneDeep } from 'lodash-es' import { cloneDeep } from 'lodash-es'
import { setColorName } from '@/utils/utils' import { setColorName } from '@/utils/utils'
import GeometryEdit from './GeometryEdit.vue' import GeometryEdit from './GeometryEdit.vue'
import { useCache } from '@/hooks/web/useCache'
import { ElMessage, ElMessageBox } from 'element-plus-secondary'
import request from '@/config/axios'
const { wsCache } = useCache()
const { t } = useI18n() const { t } = useI18n()
const keyword = ref('') const keyword = ref('')
const treeData = ref([]) const treeData = ref([])
@ -104,7 +121,7 @@ interface Tree {
children?: Tree[] children?: Tree[]
} }
const areaTreeRef = ref(null) const areaTreeRef = ref(null)
const loading = ref(false)
const selectedData = ref(null) const selectedData = ref(null)
const handleNodeClick = async (data: Tree) => { const handleNodeClick = async (data: Tree) => {
@ -119,6 +136,29 @@ const handleNodeClick = async (data: Tree) => {
} }
} }
} }
const delHandler = data => {
ElMessageBox.confirm('确定删除此节点吗', {
confirmButtonType: 'danger',
type: 'warning',
confirmButtonText: t('common.delete'),
cancelButtonText: t('dataset.cancel'),
autofocus: false,
showClose: false
})
.then(() => {
const url = '/geometry/delete/' + data.id
request.post({ url }).then(() => {
if (selectedData.value?.id === data.id) {
selectedData.value = null
}
ElMessage.success(t('common.delete_success'))
loadTreeData(false)
})
})
.catch(() => {
loading.value = false
})
}
const filterResource = val => { const filterResource = val => {
areaTreeRef.value?.filter(val) areaTreeRef.value?.filter(val)
} }
@ -128,11 +168,18 @@ const filterResourceNode = (value: string, data) => {
return data.name.toLocaleLowerCase().includes(value.toLocaleLowerCase()) return data.name.toLocaleLowerCase().includes(value.toLocaleLowerCase())
} }
const loadTreeData = () => { const loadTreeData = (cache?: boolean) => {
const key = 'de-area-tree'
const result = wsCache.get(key)
if (result && cache) {
treeData.value = result
return
}
getWorldTree() getWorldTree()
.then(res => { .then(res => {
const root = res.data const root = res.data
treeData.value = [root] treeData.value = [root]
wsCache.set(key, treeData.value)
}) })
.catch(e => { .catch(e => {
console.error(e) console.error(e)
@ -143,7 +190,7 @@ const add = (pid?: string) => {
editor?.value.edit(pid) editor?.value.edit(pid)
} }
loadTreeData() loadTreeData(true)
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
@ -165,7 +212,6 @@ loadTreeData()
line-height: 24px; line-height: 24px;
} }
.add-icon-span { .add-icon-span {
// display: none;
color: #3370ff; color: #3370ff;
height: 20px; height: 20px;
width: 20px; width: 20px;
@ -257,5 +303,16 @@ loadTreeData()
box-sizing: content-box; box-sizing: content-box;
padding-right: 4px; padding-right: 4px;
overflow: hidden; overflow: hidden;
justify-content: space-between;
.geo-operate-container {
display: none;
}
&:hover {
.geo-operate-container {
display: contents;
}
}
} }
</style> </style>

View File

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, reactive, PropType } from 'vue' import { ref, reactive } from 'vue'
import { ElMessage, ElLoading } from 'element-plus-secondary' import { ElMessage, ElLoading } from 'element-plus-secondary'
import { useI18n } from '@/hooks/web/useI18n' import { useI18n } from '@/hooks/web/useI18n'
import type { import type {
@ -10,16 +10,12 @@ import type {
} from 'element-plus-secondary' } from 'element-plus-secondary'
import request from '@/config/axios' import request from '@/config/axios'
import { GeometryFrom } from './interface' import { GeometryFrom } from './interface'
import { useCache } from '@/hooks/web/useCache'
const { wsCache } = useCache()
const { t } = useI18n() const { t } = useI18n()
const dialogVisible = ref(false) const dialogVisible = ref(false)
const loadingInstance = ref(null) const loadingInstance = ref(null)
const geoForm = ref<FormInstance>() const geoForm = ref<FormInstance>()
const props = defineProps({
treeData: {
type: Array as PropType<unknown[]>,
default: () => []
}
})
const geoFile = ref() const geoFile = ref()
const fileName = ref() const fileName = ref()
const state = reactive({ const state = reactive({
@ -27,7 +23,8 @@ const state = reactive({
pid: null, pid: null,
code: null, code: null,
name: null name: null
}) }),
treeData: []
}) })
const treeProps = { const treeProps = {
value: 'id', value: 'id',
@ -60,6 +57,8 @@ const rule = reactive<FormRules>({
}) })
const edit = (pid?: string) => { const edit = (pid?: string) => {
const key = 'de-area-tree'
state.treeData = wsCache.get(key)
state.form.pid = pid state.form.pid = pid
state.form.code = null state.form.code = null
state.form.name = null state.form.name = null
@ -75,9 +74,10 @@ const submitForm = async (formEl: FormInstance | undefined) => {
await formEl.validate((valid, fields) => { await formEl.validate((valid, fields) => {
if (valid) { if (valid) {
const param = { ...state.form } const param = { ...state.form }
const formData = buildFormData(geoFile.value, param)
showLoading() showLoading()
request request
.post({ url: '/sysParameter/map/save', data: param }) .post({ url: '/geometry/save', data: formData, headersType: 'multipart/form-data;' })
.then(res => { .then(res => {
if (!res.msg) { if (!res.msg) {
ElMessage.success(t('common.save_success')) ElMessage.success(t('common.save_success'))
@ -136,6 +136,14 @@ const uploadValidate = file => {
} }
return true return true
} }
const buildFormData = (file, param) => {
const formData = new FormData()
if (file) {
formData.append('file', file)
}
formData.append('request', new Blob([JSON.stringify(param)], { type: 'application/json' }))
return formData
}
defineExpose({ defineExpose({
edit edit
}) })
@ -163,7 +171,7 @@ defineExpose({
node-key="id" node-key="id"
v-model="state.form.pid" v-model="state.form.pid"
:props="treeProps" :props="treeProps"
:data="props.treeData" :data="state.treeData"
check-strictly check-strictly
:render-after-expand="false" :render-after-expand="false"
:placeholder="t('common.please_select')" :placeholder="t('common.please_select')"

View File

@ -0,0 +1,18 @@
package io.dataease.api.map;
import io.dataease.api.map.dto.GeometryNodeCreator;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
@Tag(name = "地理信息")
public interface GeoApi {
@PostMapping(value = "/save", consumes = {"multipart/form-data"})
void saveMapGeo(@RequestPart("request") GeometryNodeCreator request, @RequestPart(value = "file") MultipartFile file);
@PostMapping("/delete/{id}")
void deleteGeo(@PathVariable("id") String id);
}

View File

@ -0,0 +1,15 @@
package io.dataease.api.map.dto;
import lombok.Data;
import java.io.Serializable;
@Data
public class GeometryNodeCreator implements Serializable {
private String code;
private String name;
private String pid;
}

View File

@ -18,6 +18,7 @@ public class AreaNode implements Serializable {
private String level; private String level;
private String name; private String name;
private String pid; private String pid;
private boolean custom = false;
/** /**
* 国家代码 * 国家代码
*/ */

View File

@ -16,8 +16,10 @@ public class StaticResourceConstants {
public static String WORK_DIR = ensureSuffix(USER_HOME, FILE_SEPARATOR) + "static-resource" + FILE_SEPARATOR; public static String WORK_DIR = ensureSuffix(USER_HOME, FILE_SEPARATOR) + "static-resource" + FILE_SEPARATOR;
public static String MAP_DIR = ensureSuffix(USER_HOME, FILE_SEPARATOR) + "map"; public static String MAP_DIR = ensureSuffix(USER_HOME, FILE_SEPARATOR) + "map";
public static String CUSTOM_MAP_DIR = ensureSuffix(USER_HOME, FILE_SEPARATOR) + "geo";
public static String MAP_URL = "/map"; public static String MAP_URL = "/map";
public static String GEO_URL = "/geo";
/** /**
* Upload prefix. * Upload prefix.

View File

@ -36,7 +36,7 @@ public class WhitelistUtils {
|| StringUtils.startsWithAny(requestURI, "/static-resource/") || StringUtils.startsWithAny(requestURI, "/static-resource/")
|| StringUtils.startsWithAny(requestURI, "/share/proxyInfo") || StringUtils.startsWithAny(requestURI, "/share/proxyInfo")
|| StringUtils.startsWithAny(requestURI, "/xpackComponent/content/") || StringUtils.startsWithAny(requestURI, "/xpackComponent/content/")
|| StringUtils.startsWithAny(requestURI, "/platform/") || StringUtils.startsWithAny(requestURI, "/geo/")
|| StringUtils.startsWithAny(requestURI, "/map/"); || StringUtils.startsWithAny(requestURI, "/map/");
} }
} }