555 lines
18 KiB
TypeScript
555 lines
18 KiB
TypeScript
import type {
|
|
GuideModelFloorAsset,
|
|
GuideModelRenderPackage,
|
|
GuideModelSource,
|
|
GuideRenderPoi
|
|
} from '@/domain/guideModel'
|
|
import type {
|
|
MuseumPoi
|
|
} from '@/domain/museum'
|
|
import {
|
|
isVisitorSearchPoi
|
|
} from '@/domain/poiCategories'
|
|
import {
|
|
getPoiDisplayPolicy
|
|
} from '@/domain/poiDisplay'
|
|
import {
|
|
dataSourceConfig
|
|
} from '@/config/dataSource'
|
|
import {
|
|
compareFloorsTopToBottom,
|
|
isIndoorNavigableFloor
|
|
} from '@/domain/guideFloor'
|
|
import {
|
|
buildSgsFloorAliases,
|
|
createSgsHallPoiDiagnostics,
|
|
formatSgsFloorLabel,
|
|
toMuseumHallPoisFromSgs,
|
|
toMuseumPoiFromSgs
|
|
} from '@/data/adapters/sgsSdkGuideAdapter'
|
|
import {
|
|
defaultSgsSdkApiProvider,
|
|
type SgsSdkApiProvider,
|
|
type SgsSdkFloorSummaryPayload
|
|
} from '@/data/providers/sgsSdkApiProvider'
|
|
import {
|
|
formatNavFloorLabel,
|
|
isStaticIndoorNavigableFloorId,
|
|
toMuseumPoi
|
|
} from '@/data/adapters/navAssetsAdapter'
|
|
import {
|
|
defaultStaticNavAssetsProvider,
|
|
type StaticNavAssetsProvider
|
|
} from '@/data/providers/staticNavAssetsProvider'
|
|
import {
|
|
mapWithConcurrency
|
|
} from '@/utils/concurrency'
|
|
import type {
|
|
GuideRepository
|
|
} from '@/repositories/GuideRepository'
|
|
import {
|
|
guideRepository
|
|
} from '@/repositories/createGuideRepository'
|
|
import {
|
|
normalizeSameOriginPublicUrl
|
|
} from '@/utils/publicUrl'
|
|
|
|
const SDK_FLOOR_BUNDLE_CONCURRENCY = 2
|
|
|
|
const getNow = () => (
|
|
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
|
? performance.now()
|
|
: Date.now()
|
|
)
|
|
|
|
const toGuideRenderPoi = (poi: MuseumPoi): GuideRenderPoi => ({
|
|
id: poi.id,
|
|
name: poi.name,
|
|
floorId: poi.floorId,
|
|
primaryCategory: poi.primaryCategory.id,
|
|
primaryCategoryZh: poi.primaryCategory.label,
|
|
iconType: poi.primaryCategory.iconType || poi.primaryCategory.id,
|
|
positionGltf: poi.positionGltf,
|
|
sourceObjectName: poi.sourceObjectName,
|
|
kind: poi.kind,
|
|
hallId: poi.hallId,
|
|
hallName: poi.hallName,
|
|
spaceId: poi.spaceId,
|
|
sourcePlaceId: poi.sourcePlaceId,
|
|
sourceSpaceId: poi.sourceSpaceId,
|
|
entrances: poi.entrances,
|
|
displayPolicy: poi.displayPolicy || getPoiDisplayPolicy({
|
|
primaryCategory: poi.primaryCategory.id,
|
|
kind: poi.kind
|
|
})
|
|
})
|
|
|
|
const toSgsRenderPoi = (poi: ReturnType<typeof toMuseumPoiFromSgs>) => toGuideRenderPoi(poi)
|
|
|
|
const getRenderPoiDedupeKeys = (poi: GuideRenderPoi) => {
|
|
// A facility can be located inside the same spatial area as other facilities.
|
|
// That relationship must not collapse distinct visitor markers (for example,
|
|
// a restroom and an elevator in one service zone). Space identity is only a
|
|
// canonical-place key for halls, spaces, and business-place representations.
|
|
const canDedupeBySpace = poi.kind === 'hall'
|
|
|| poi.kind === 'space'
|
|
|| poi.primaryCategory === 'business_poi'
|
|
const stableKeys = [
|
|
poi.id ? `id:${poi.id}` : '',
|
|
canDedupeBySpace && poi.sourceSpaceId ? `space:${poi.floorId}:${poi.sourceSpaceId}` : '',
|
|
canDedupeBySpace && poi.spaceId ? `space:${poi.floorId}:${poi.spaceId}` : '',
|
|
poi.sourcePlaceId ? `place:${poi.floorId}:${poi.sourcePlaceId}` : ''
|
|
].filter(Boolean)
|
|
|
|
if (stableKeys.length) return stableKeys
|
|
return poi.sourceObjectName ? [`object:${poi.floorId}:${poi.sourceObjectName}`] : []
|
|
}
|
|
|
|
const dedupeRenderPoisById = (pois: GuideRenderPoi[]) => {
|
|
const seen = new Set<string>()
|
|
return pois.filter((poi) => {
|
|
const keys = getRenderPoiDedupeKeys(poi)
|
|
if (!keys.length || keys.some((key) => seen.has(key))) return false
|
|
keys.forEach((key) => seen.add(key))
|
|
return true
|
|
})
|
|
}
|
|
|
|
const countByValue = <T>(
|
|
items: T[],
|
|
selector: (item: T) => string | number | null | undefined
|
|
) => items.reduce<Record<string, number>>((counts, item) => {
|
|
const key = String(selector(item) || '<missing>')
|
|
counts[key] = (counts[key] || 0) + 1
|
|
return counts
|
|
}, {})
|
|
|
|
const countDroppedRenderPoiCategories = (
|
|
sourcePois: GuideRenderPoi[],
|
|
keptPois: GuideRenderPoi[]
|
|
) => {
|
|
const keptIds = new Set(keptPois.map((poi) => poi.id))
|
|
return countByValue(
|
|
sourcePois.filter((poi) => !keptIds.has(poi.id)),
|
|
(poi) => poi.primaryCategory
|
|
)
|
|
}
|
|
|
|
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, '')
|
|
|
|
const resolveSgsAssetUrl = (url?: string | null) => {
|
|
const normalizedUrl = url?.trim()
|
|
if (!normalizedUrl) return ''
|
|
|
|
if (/^https?:\/\//i.test(normalizedUrl)) {
|
|
return normalizeSameOriginPublicUrl(normalizedUrl)
|
|
}
|
|
|
|
if (!normalizedUrl.startsWith('/')) return normalizedUrl
|
|
|
|
const baseUrl = trimTrailingSlash(dataSourceConfig.sgsApiBaseUrl || dataSourceConfig.apiBaseUrl || '')
|
|
const origin = baseUrl.endsWith('/app-api')
|
|
? baseUrl.slice(0, -'/app-api'.length)
|
|
: baseUrl
|
|
|
|
return origin ? `${origin}${normalizedUrl}` : normalizedUrl
|
|
}
|
|
|
|
const uniqueModelUrls = (urls: Array<string | undefined | null>) => Array.from(new Set(
|
|
urls.map(resolveSgsAssetUrl).filter(Boolean)
|
|
))
|
|
|
|
const sortSgsFloors = (floors: SgsSdkFloorSummaryPayload[]) => [...floors]
|
|
.sort(compareFloorsTopToBottom)
|
|
|
|
const isSharedSgsModelAsset = (
|
|
modelUrl: string,
|
|
overviewModelUrl: string,
|
|
allModelUrls: string[]
|
|
) => {
|
|
if (!modelUrl) return false
|
|
|
|
return modelUrl === overviewModelUrl || allModelUrls.filter((url) => url === modelUrl).length > 1
|
|
}
|
|
|
|
const warnSgsGuideModelDiagnostics = (
|
|
message: string,
|
|
payload: Record<string, unknown>
|
|
) => {
|
|
if (!import.meta.env.DEV) return
|
|
console.warn(`[SGS guide model] ${message}`, payload)
|
|
}
|
|
|
|
const stringifyDiagnosticPayload = (payload: Record<string, unknown>) => {
|
|
try {
|
|
return JSON.stringify(payload)
|
|
} catch {
|
|
return '[unserializable]'
|
|
}
|
|
}
|
|
|
|
const logSgsGuideModelDiagnostics = (
|
|
message: string,
|
|
payload: Record<string, unknown>
|
|
) => {
|
|
if (!import.meta.env.DEV) return
|
|
if (typeof window !== 'undefined') {
|
|
const diagnosticsWindow = window as unknown as {
|
|
__GUIDE_3D_DIAGNOSTIC_EVENTS__?: Array<{
|
|
source: string
|
|
event: string
|
|
payload: Record<string, unknown>
|
|
}>
|
|
}
|
|
diagnosticsWindow.__GUIDE_3D_DIAGNOSTIC_EVENTS__ ||= []
|
|
diagnosticsWindow.__GUIDE_3D_DIAGNOSTIC_EVENTS__.push({
|
|
source: 'SGS guide model',
|
|
event: message,
|
|
payload
|
|
})
|
|
}
|
|
console.debug(`[SGS guide model] ${message} ${stringifyDiagnosticPayload(payload)}`)
|
|
}
|
|
|
|
const summarizeYValues = (pois: GuideRenderPoi[]) => {
|
|
const values = pois
|
|
.map((poi) => poi.positionGltf?.[1])
|
|
.filter((value): value is number => Number.isFinite(value))
|
|
.sort((left, right) => left - right)
|
|
|
|
if (!values.length) {
|
|
return {
|
|
count: 0,
|
|
min: null,
|
|
median: null,
|
|
max: null
|
|
}
|
|
}
|
|
|
|
return {
|
|
count: values.length,
|
|
min: values[0],
|
|
median: values[Math.floor(values.length / 2)],
|
|
max: values[values.length - 1]
|
|
}
|
|
}
|
|
|
|
const getMedianPoiY = (pois: GuideRenderPoi[]) => {
|
|
const values = pois
|
|
.map((poi) => poi.positionGltf?.[1])
|
|
.filter((value): value is number => Number.isFinite(value))
|
|
.sort((left, right) => left - right)
|
|
|
|
if (!values.length) return undefined
|
|
|
|
const middle = Math.floor(values.length / 2)
|
|
return values.length % 2
|
|
? values[middle]
|
|
: (values[middle - 1] + values[middle]) / 2
|
|
}
|
|
|
|
const getSgsFloorModelMatchKeys = (floor: SgsSdkFloorSummaryPayload, label: string) => (
|
|
[
|
|
String(floor.floorId),
|
|
floor.floorCode,
|
|
floor.floorName,
|
|
label
|
|
]
|
|
.filter((value): value is string => Boolean(value))
|
|
)
|
|
|
|
export interface GuideModelRepository extends GuideModelSource {}
|
|
|
|
export class StaticGuideModelRepository implements GuideModelRepository {
|
|
constructor(private readonly provider: StaticNavAssetsProvider = defaultStaticNavAssetsProvider) {}
|
|
|
|
async loadPackage(): Promise<GuideModelRenderPackage> {
|
|
const [manifest, floorIndex] = await Promise.all([
|
|
this.provider.loadManifest(),
|
|
this.provider.loadFloorIndex()
|
|
])
|
|
|
|
const floors: GuideModelFloorAsset[] = [...floorIndex.floors]
|
|
.filter((floor) => isStaticIndoorNavigableFloorId(floor.floorId))
|
|
.sort(compareFloorsTopToBottom)
|
|
.map((floor) => ({
|
|
floorId: floor.floorId,
|
|
label: formatNavFloorLabel(floor.floorId),
|
|
order: floor.order,
|
|
modelUrl: this.provider.assetUrl(floor.modelAsset),
|
|
sharedModelAsset: floor.sharedModelAsset
|
|
}))
|
|
|
|
return {
|
|
overviewModelUrl: this.provider.assetUrl(manifest.assets.overviewModel.asset),
|
|
floors
|
|
}
|
|
}
|
|
|
|
async loadFloorPois(floorId: string) {
|
|
const floorIndex = await this.provider.loadFloorIndex()
|
|
const floor = floorIndex.floors.find((item) => item.floorId === floorId)
|
|
if (!floor || !isStaticIndoorNavigableFloorId(floor.floorId)) return []
|
|
|
|
const pois = await this.provider.loadFloorPois(floor.poiDataAsset)
|
|
return pois
|
|
.map(toMuseumPoi)
|
|
.filter(isVisitorSearchPoi)
|
|
.map(toGuideRenderPoi)
|
|
.filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3)
|
|
}
|
|
}
|
|
|
|
export class SgsSdkGuideModelRepository implements GuideModelRepository {
|
|
private floorsCache: SgsSdkFloorSummaryPayload[] | null = null
|
|
|
|
constructor(
|
|
private readonly provider: SgsSdkApiProvider = defaultSgsSdkApiProvider,
|
|
private readonly guide: GuideRepository = guideRepository
|
|
) {}
|
|
|
|
async loadPackage(): Promise<GuideModelRenderPackage> {
|
|
const startedAt = getNow()
|
|
const manifest = await this.provider.getManifest()
|
|
const allFloors = sortSgsFloors(manifest.floors)
|
|
const floors = allFloors.filter(isIndoorNavigableFloor)
|
|
const overviewFloor = allFloors.find((floor) => !isIndoorNavigableFloor(floor))
|
|
const overviewBundleRequest = overviewFloor
|
|
? this.provider.getFloorBundle(String(overviewFloor.floorId)).catch((error) => {
|
|
warnSgsGuideModelDiagnostics('overview bundle request failed', {
|
|
floorId: String(overviewFloor.floorId),
|
|
floorCode: overviewFloor.floorCode,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
})
|
|
return null
|
|
})
|
|
: Promise.resolve(null)
|
|
|
|
this.floorsCache = floors
|
|
|
|
const bundleResults = await mapWithConcurrency(
|
|
floors,
|
|
SDK_FLOOR_BUNDLE_CONCURRENCY,
|
|
async (floor) => {
|
|
try {
|
|
return {
|
|
floor,
|
|
bundle: await this.provider.getFloorBundle(String(floor.floorId)),
|
|
error: null
|
|
}
|
|
} catch (error) {
|
|
warnSgsGuideModelDiagnostics('floor bundle request failed', {
|
|
floorId: String(floor.floorId),
|
|
floorCode: floor.floorCode,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
})
|
|
return {
|
|
floor,
|
|
bundle: null,
|
|
error
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
const floorModelItems: GuideModelFloorAsset[] = bundleResults
|
|
.map((result): GuideModelFloorAsset | null => {
|
|
const { floor, bundle } = result
|
|
if (!bundle) return null
|
|
|
|
const bundleFloor = bundle.floor
|
|
if (bundleFloor && String(bundleFloor.floorId) !== String(floor.floorId)) {
|
|
warnSgsGuideModelDiagnostics('floor bundle metadata does not match requested floor', {
|
|
requestedFloorId: String(floor.floorId),
|
|
requestedFloorCode: floor.floorCode,
|
|
bundleFloorId: String(bundleFloor.floorId),
|
|
bundleFloorCode: bundleFloor.floorCode
|
|
})
|
|
}
|
|
|
|
const modelUrls = uniqueModelUrls([
|
|
bundle.model?.modelUrl,
|
|
bundle.model?.fallbackModelUrl
|
|
])
|
|
const modelUrl = modelUrls[0] || ''
|
|
const label = formatSgsFloorLabel(floor.floorCode, floor.floorName)
|
|
|
|
return {
|
|
floorId: String(floor.floorId),
|
|
label,
|
|
order: Number(floor.sortOrder || 0),
|
|
modelUrl,
|
|
modelUrls,
|
|
modelMatchKeys: getSgsFloorModelMatchKeys(floor, label)
|
|
}
|
|
})
|
|
.filter((asset): asset is GuideModelFloorAsset => Boolean(asset?.modelUrl))
|
|
|
|
const overviewBundle = await overviewBundleRequest
|
|
const overviewModelUrls = uniqueModelUrls([
|
|
overviewBundle?.model?.modelUrl,
|
|
overviewBundle?.model?.fallbackModelUrl,
|
|
floorModelItems[0]?.modelUrl
|
|
])
|
|
const overviewModelUrl = overviewModelUrls[0] || ''
|
|
const floorModelUrls = floorModelItems.map((asset) => asset.modelUrl)
|
|
const floorAssets: GuideModelFloorAsset[] = floorModelItems.map((asset) => ({
|
|
...asset,
|
|
sharedModelAsset: isSharedSgsModelAsset(asset.modelUrl, overviewModelUrl, floorModelUrls)
|
|
}))
|
|
logSgsGuideModelDiagnostics('render package ready', {
|
|
elapsedMs: Math.round(getNow() - startedAt),
|
|
mapId: manifest.mapId,
|
|
mode: dataSourceConfig.guideDataMode,
|
|
overviewFloorId: overviewFloor ? String(overviewFloor.floorId) : '',
|
|
overviewModelUrl,
|
|
floorCount: floorAssets.length,
|
|
floorBundleCount: bundleResults.filter((result) => Boolean(result.bundle)).length
|
|
})
|
|
|
|
return {
|
|
overviewModelUrl,
|
|
overviewModelUrls,
|
|
floors: floorAssets
|
|
}
|
|
}
|
|
|
|
async loadFloorPois(floorId: string) {
|
|
const manifest = await this.provider.getManifest()
|
|
this.floorsCache = sortSgsFloors(manifest.floors).filter(isIndoorNavigableFloor)
|
|
const aliases = buildSgsFloorAliases(this.floorsCache)
|
|
const requestedFloorId = floorId.trim()
|
|
const normalizedFloorId = aliases.get(requestedFloorId)
|
|
|| aliases.get(requestedFloorId.toLowerCase())
|
|
|| requestedFloorId
|
|
const matchedFloor = this.floorsCache.find((floor) => (
|
|
String(floor.floorId) === normalizedFloorId
|
|
|| floor.floorCode === normalizedFloorId
|
|
|| formatSgsFloorLabel(floor.floorCode, floor.floorName) === normalizedFloorId
|
|
))
|
|
if (!matchedFloor) {
|
|
warnSgsGuideModelDiagnostics('unable to resolve floor for render POI request', {
|
|
requestedFloorId: floorId,
|
|
normalizedFloorId,
|
|
knownFloors: this.floorsCache.map((floor) => ({
|
|
floorId: String(floor.floorId),
|
|
floorCode: floor.floorCode,
|
|
label: formatSgsFloorLabel(floor.floorCode, floor.floorName)
|
|
}))
|
|
})
|
|
return []
|
|
}
|
|
|
|
const resolvedFloorId = String(matchedFloor.floorId)
|
|
const loadFloorData = async <T>(
|
|
endpoint: 'pois' | 'spaces' | 'navigablePlaces' | 'guidePois' | 'guideSpacePoints',
|
|
loader: () => Promise<T[]>
|
|
) => {
|
|
try {
|
|
return await loader()
|
|
} catch (error) {
|
|
warnSgsGuideModelDiagnostics('floor data request failed', {
|
|
floorId: resolvedFloorId,
|
|
endpoint,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
})
|
|
return []
|
|
}
|
|
}
|
|
|
|
const [pois, spaces, navigablePlaces, guidePois, guideSpacePoints] = await Promise.all([
|
|
loadFloorData('pois', () => this.provider.getFloorPois(resolvedFloorId)),
|
|
loadFloorData('spaces', () => this.provider.getFloorSpaces(resolvedFloorId)),
|
|
loadFloorData('navigablePlaces', () => this.provider.getNavigablePlaces(resolvedFloorId)),
|
|
loadFloorData('guidePois', () => this.guide.listPois()),
|
|
loadFloorData('guideSpacePoints', () => this.guide.listSpacePoints())
|
|
])
|
|
const ordinaryPois = pois
|
|
.map((poi) => toMuseumPoiFromSgs(poi, manifest.floors))
|
|
.filter(isVisitorSearchPoi)
|
|
.map(toSgsRenderPoi)
|
|
const repositoryBusinessPois = guidePois
|
|
.filter((poi) => (
|
|
poi.floorId === resolvedFloorId
|
|
&& poi.primaryCategory.id === 'business_poi'
|
|
&& isVisitorSearchPoi(poi)
|
|
))
|
|
.map(toGuideRenderPoi)
|
|
const repositorySpacePois = guideSpacePoints
|
|
.filter((poi) => poi.floorId === resolvedFloorId && isVisitorSearchPoi(poi))
|
|
.map(toGuideRenderPoi)
|
|
const floorPoiMedianY = getMedianPoiY(ordinaryPois)
|
|
const museumHallPois = toMuseumHallPoisFromSgs(spaces, navigablePlaces, manifest.floors, resolvedFloorId, {
|
|
fallbackY: floorPoiMedianY
|
|
})
|
|
const hallDiagnostics = createSgsHallPoiDiagnostics(spaces, navigablePlaces, museumHallPois)
|
|
|
|
if (hallDiagnostics.eligibleSpaceCount > 0 && hallDiagnostics.hallPoiWithPositionCount === 0) {
|
|
warnSgsGuideModelDiagnostics('eligible hall spaces produced no renderable hall POIs', {
|
|
floorId: resolvedFloorId,
|
|
...hallDiagnostics
|
|
})
|
|
}
|
|
|
|
const hallPois = museumHallPois
|
|
.filter(isVisitorSearchPoi)
|
|
.map(toSgsRenderPoi)
|
|
const adaptedPois = dedupeRenderPoisById([
|
|
...hallPois,
|
|
...ordinaryPois,
|
|
...repositorySpacePois,
|
|
...repositoryBusinessPois
|
|
])
|
|
const floorMatchedPois = adaptedPois.filter((poi) => poi.floorId === resolvedFloorId)
|
|
const renderPois = floorMatchedPois
|
|
.filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3)
|
|
|
|
const renderHallPois = renderPois.filter((poi) => poi.kind === 'hall' || poi.primaryCategory === 'exhibition_hall')
|
|
logSgsGuideModelDiagnostics('floor POI category diagnostics', {
|
|
floorId: resolvedFloorId,
|
|
floorCode: matchedFloor.floorCode,
|
|
rawPoiCount: pois.length,
|
|
rawRepositoryBusinessPoiCount: repositoryBusinessPois.length,
|
|
rawRepositorySpacePoiCount: repositorySpacePois.length,
|
|
rawPoiTypeCounts: countByValue(pois, (poi) => poi.type),
|
|
rawPoiGroupCounts: countByValue(pois, (poi) => poi.poiGroup),
|
|
adaptedPoiCount: adaptedPois.length,
|
|
adaptedCategoryCounts: countByValue(adaptedPois, (poi) => poi.primaryCategory),
|
|
adaptedPoiCategoryCount: adaptedPois.filter((poi) => poi.primaryCategory === 'poi').length,
|
|
renderPoiCount: renderPois.length,
|
|
renderPoiCategoryCount: renderPois.filter((poi) => poi.primaryCategory === 'poi').length,
|
|
repositoryDroppedCategoryCounts: countDroppedRenderPoiCategories(adaptedPois, renderPois)
|
|
})
|
|
warnSgsGuideModelDiagnostics('floor render POI diagnostics', {
|
|
floorId: resolvedFloorId,
|
|
floorCode: matchedFloor.floorCode,
|
|
poiCount: renderPois.length,
|
|
hallPoiCount: renderHallPois.length,
|
|
hallPoiWithPositionCount: renderHallPois.filter((poi) => Boolean(poi.positionGltf)).length,
|
|
poiY: summarizeYValues(renderPois),
|
|
hallPoiY: summarizeYValues(renderHallPois),
|
|
fallbackHallY: floorPoiMedianY ?? null
|
|
})
|
|
|
|
if (hallDiagnostics.hallPoiWithPositionCount > 0 && !renderHallPois.length) {
|
|
warnSgsGuideModelDiagnostics('render POI filter removed all hall POIs', {
|
|
floorId: resolvedFloorId,
|
|
...hallDiagnostics,
|
|
renderPoiCount: renderPois.length
|
|
})
|
|
}
|
|
|
|
return renderPois
|
|
}
|
|
}
|
|
|
|
export const createGuideModelRepository = (): GuideModelRepository => {
|
|
if (dataSourceConfig.guideDataMode === 'api' || dataSourceConfig.guideDataMode === 'sdk') {
|
|
return new SgsSdkGuideModelRepository()
|
|
}
|
|
|
|
return new StaticGuideModelRepository()
|
|
}
|
|
|
|
export const guideModelRepository = createGuideModelRepository()
|