Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
canifa_note
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
canifa_note
Commits
894b3eb0
Commit
894b3eb0
authored
Apr 06, 2026
by
boojack
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix(map): refine Leaflet controls and memo map styling
parent
25feef3a
Changes
3
Hide whitespace changes
Inline
Side-by-side
Showing
3 changed files
with
98 additions
and
64 deletions
+98
-64
UserMemoMap.tsx
web/src/components/UserMemoMap/UserMemoMap.tsx
+56
-22
LocationPicker.tsx
web/src/components/map/LocationPicker.tsx
+33
-38
map-utils.tsx
web/src/components/map/map-utils.tsx
+9
-4
No files found.
web/src/components/UserMemoMap/UserMemoMap.tsx
View file @
894b3eb0
...
@@ -25,8 +25,8 @@ interface ClusterGroup {
...
@@ -25,8 +25,8 @@ interface ClusterGroup {
const
createClusterCustomIcon
=
(
cluster
:
ClusterGroup
)
=>
{
const
createClusterCustomIcon
=
(
cluster
:
ClusterGroup
)
=>
{
return
new
DivIcon
({
return
new
DivIcon
({
html
:
`<span class="flex
items-center justify-center w-full h-full bg-primary text-primary-foreground text-xs font-bold rounded-full shadow-md border-2 border-background
">
${
cluster
.
getChildCount
()}
</span>`
,
html
:
`<span class="flex
h-8 w-8 items-center justify-center rounded-full border border-border bg-background/95 text-xs font-semibold text-foreground shadow-sm backdrop-blur-sm
">
${
cluster
.
getChildCount
()}
</span>`
,
className
:
"
custom-marker-cluster
"
,
className
:
"
border-none bg-transparent
"
,
iconSize
:
L
.
point
(
32
,
32
,
true
),
iconSize
:
L
.
point
(
32
,
32
,
true
),
});
});
};
};
...
@@ -67,17 +67,41 @@ const UserMemoMap = ({ creator, className }: Props) => {
...
@@ -67,17 +67,41 @@ const UserMemoMap = ({ creator, className }: Props) => {
const
defaultCenter
=
{
lat
:
48.8566
,
lng
:
2.3522
};
const
defaultCenter
=
{
lat
:
48.8566
,
lng
:
2.3522
};
return
(
return
(
<
div
className=
{
cn
(
"relative z-0 w-full h-[380px] rounded-xl overflow-hidden border border-border shadow-sm"
,
className
)
}
>
<
div
className=
{
cn
(
"memo-user-map relative z-0 h-[380px] w-full overflow-hidden rounded-xl border border-border bg-background shadow-sm"
,
className
,
)
}
>
{
memosWithLocation
.
length
===
0
&&
(
{
memosWithLocation
.
length
===
0
&&
(
<
div
className=
"absolute inset-0 z-[1000] flex items-center justify-center pointer-events-none"
>
<
div
className=
"absolute inset-0 z-[1000] flex items-center justify-center pointer-events-none"
>
<
div
className=
"flex flex-col items-center gap-1
rounded-2xl border border-border bg-background/70 px-4 py-2
shadow-sm backdrop-blur-sm"
>
<
div
className=
"flex flex-col items-center gap-1
.5 rounded-xl border border-border bg-background/92 px-5 py-3
shadow-sm backdrop-blur-sm"
>
<
MapPinIcon
className=
"h-5 w-5 text-muted-foreground opacity-
6
0"
/>
<
MapPinIcon
className=
"h-5 w-5 text-muted-foreground opacity-
7
0"
/>
<
p
className=
"text-xs font-medium text-muted-foreground"
>
No location data found
</
p
>
<
p
className=
"text-xs font-medium t
racking-[0.02em] t
ext-muted-foreground"
>
No location data found
</
p
>
</
div
>
</
div
>
</
div
>
</
div
>
)
}
)
}
<
MapContainer
center=
{
defaultCenter
}
zoom=
{
2
}
className=
"h-full w-full z-0"
scrollWheelZoom
attributionControl=
{
false
}
>
<
div
className=
"pointer-events-none absolute left-4 top-4 z-[950] flex items-start justify-between gap-3 rounded-xl border border-border bg-background/92 px-3 py-2.5 shadow-sm backdrop-blur-sm"
>
<
div
className=
"flex items-center gap-2"
>
<
span
className=
"grid size-7 place-items-center rounded-full bg-primary/10 text-primary"
>
<
MapPinIcon
className=
"size-3.5"
/>
</
span
>
<
div
className=
"min-w-0"
>
<
p
className=
"text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground"
>
Mapped memos
</
p
>
<
p
className=
"text-sm font-semibold text-foreground"
>
{
memosWithLocation
.
length
}
places pinned
</
p
>
</
div
>
</
div
>
</
div
>
<
MapContainer
center=
{
defaultCenter
}
zoom=
{
2
}
className=
"h-full w-full z-0"
scrollWheelZoom
zoomControl=
{
false
}
attributionControl=
{
false
}
>
<
ThemedTileLayer
/>
<
ThemedTileLayer
/>
<
MarkerClusterGroup
<
MarkerClusterGroup
chunkedLoading
chunkedLoading
...
@@ -88,26 +112,36 @@ const UserMemoMap = ({ creator, className }: Props) => {
...
@@ -88,26 +112,36 @@ const UserMemoMap = ({ creator, className }: Props) => {
>
>
{
memosWithLocation
.
map
((
memo
)
=>
(
{
memosWithLocation
.
map
((
memo
)
=>
(
<
Marker
key=
{
memo
.
name
}
position=
{
[
memo
.
location
!
.
latitude
,
memo
.
location
!
.
longitude
]
}
icon=
{
defaultMarkerIcon
}
>
<
Marker
key=
{
memo
.
name
}
position=
{
[
memo
.
location
!
.
latitude
,
memo
.
location
!
.
longitude
]
}
icon=
{
defaultMarkerIcon
}
>
<
Popup
closeButton=
{
false
}
className=
"w-48!"
>
<
Popup
closeButton=
{
false
}
className=
"memo-map-popup w-64!"
>
<
div
className=
"flex flex-col p-0.5"
>
<
div
className=
"flex flex-col gap-2.5 p-3"
>
<
div
className=
"flex items-center justify-between border-b border-border pb-1 mb-1"
>
<
div
className=
"flex items-start justify-between gap-3"
>
<
span
className=
"text-[10px] font-medium text-muted-foreground"
>
<
div
className=
"space-y-1"
>
{
memo
.
displayTime
&&
<
span
className=
"inline-flex rounded-full border border-border/70 bg-muted/50 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground"
>
timestampDate
(
memo
.
displayTime
).
toLocaleDateString
(
undefined
,
{
Memo
year
:
"numeric"
,
</
span
>
month
:
"short"
,
<
span
className=
"block text-[11px] font-medium text-muted-foreground"
>
day
:
"numeric"
,
{
memo
.
displayTime
&&
})
}
timestampDate
(
memo
.
displayTime
).
toLocaleDateString
(
undefined
,
{
</
span
>
year
:
"numeric"
,
month
:
"short"
,
day
:
"numeric"
,
})
}
</
span
>
</
div
>
<
Link
<
Link
to=
{
`/memos/${memo.name.split("/").pop()}`
}
to=
{
`/memos/${memo.name.split("/").pop()}`
}
className=
"
flex items-center gap-0.5 text-[10px] text-primary hover:opacity-80
"
className=
"
inline-flex items-center gap-1 rounded-full border border-border bg-background px-2.5 py-1 text-[11px] font-medium text-foreground transition-all hover:border-primary/40 hover:text-primary
"
>
>
View
Open
<
ArrowUpRightIcon
className=
"h-3
w-3
"
/>
<
ArrowUpRightIcon
className=
"h-3
.5 w-3.5
"
/>
</
Link
>
</
Link
>
</
div
>
</
div
>
<
div
className=
"line-clamp-3 py-0.5 text-xs font-sans leading-snug text-foreground"
>
{
memo
.
snippet
||
"No content"
}
</
div
>
<
div
className=
"space-y-1"
>
<
div
className=
"line-clamp-3 text-sm leading-snug font-medium text-foreground"
>
{
memo
.
snippet
||
"No content"
}
</
div
>
<
div
className=
"text-[11px] text-muted-foreground"
>
{
memo
.
location
!
.
latitude
.
toFixed
(
2
)
}
°,
{
memo
.
location
!
.
longitude
.
toFixed
(
2
)
}
°
</
div
>
</
div
>
</
div
>
</
div
>
</
Popup
>
</
Popup
>
</
Marker
>
</
Marker
>
...
...
web/src/components/map/LocationPicker.tsx
View file @
894b3eb0
import
L
,
{
LatLng
}
from
"leaflet"
;
import
L
,
{
LatLng
}
from
"leaflet"
;
import
{
ExternalLinkIcon
,
MinusIcon
,
PlusIcon
}
from
"lucide-react"
;
import
{
ExternalLinkIcon
,
MinusIcon
,
PlusIcon
}
from
"lucide-react"
;
import
{
type
ReactNode
,
useEffect
,
useRef
,
useState
}
from
"react"
;
import
{
type
ReactNode
,
useEffect
,
useRef
,
useState
}
from
"react"
;
import
{
create
Root
}
from
"react-dom/client
"
;
import
{
create
Portal
}
from
"react-dom
"
;
import
{
MapContainer
,
Marker
,
useMap
,
useMapEvents
}
from
"react-leaflet"
;
import
{
MapContainer
,
Marker
,
useMap
,
useMapEvents
}
from
"react-leaflet"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
defaultMarkerIcon
,
ThemedTileLayer
}
from
"./map-utils"
;
import
{
defaultMarkerIcon
,
ThemedTileLayer
}
from
"./map-utils"
;
...
@@ -135,7 +135,7 @@ interface MapControlsProps {
...
@@ -135,7 +135,7 @@ interface MapControlsProps {
const
MapControls
=
({
position
}:
MapControlsProps
)
=>
{
const
MapControls
=
({
position
}:
MapControlsProps
)
=>
{
const
map
=
useMap
();
const
map
=
useMap
();
const
controlRef
=
useRef
<
MapControlsContainer
|
null
>
(
null
);
const
controlRef
=
useRef
<
MapControlsContainer
|
null
>
(
null
);
const
rootRef
=
useRef
<
ReturnType
<
typeof
createRoot
>
|
null
>
(
null
);
const
[
container
,
setContainer
]
=
useState
<
HTMLDivElement
|
null
>
(
null
);
const
handleOpenInGoogleMaps
=
()
=>
{
const
handleOpenInGoogleMaps
=
()
=>
{
if
(
!
position
)
return
;
if
(
!
position
)
return
;
...
@@ -156,39 +156,25 @@ const MapControls = ({ position }: MapControlsProps) => {
...
@@ -156,39 +156,25 @@ const MapControls = ({ position }: MapControlsProps) => {
const
control
=
new
MapControlsContainer
({
position
:
"topright"
});
const
control
=
new
MapControlsContainer
({
position
:
"topright"
});
controlRef
.
current
=
control
;
controlRef
.
current
=
control
;
control
.
addTo
(
map
);
control
.
addTo
(
map
);
setContainer
(
control
.
getContainer
()
??
null
);
// Get container and render React component into it
const
container
=
control
.
getContainer
();
if
(
container
)
{
rootRef
.
current
=
createRoot
(
container
);
rootRef
.
current
.
render
(
<
ControlButtons
position=
{
position
}
onZoomIn=
{
handleZoomIn
}
onZoomOut=
{
handleZoomOut
}
onOpenGoogleMaps=
{
handleOpenInGoogleMaps
}
/>,
);
}
return
()
=>
{
return
()
=>
{
// Cleanup: unmount React component and remove control
if
(
rootRef
.
current
)
{
rootRef
.
current
.
unmount
();
rootRef
.
current
=
null
;
}
if
(
controlRef
.
current
)
{
if
(
controlRef
.
current
)
{
controlRef
.
current
.
remove
();
controlRef
.
current
.
remove
();
controlRef
.
current
=
null
;
controlRef
.
current
=
null
;
}
}
setContainer
(
null
);
};
};
},
[
map
]);
},
[
map
]);
// Update rendered content when position changes
if
(
!
container
)
{
useEffect
(()
=>
{
return
null
;
if
(
rootRef
.
current
)
{
}
rootRef
.
current
.
render
(
<
ControlButtons
position=
{
position
}
onZoomIn=
{
handleZoomIn
}
onZoomOut=
{
handleZoomOut
}
onOpenGoogleMaps=
{
handleOpenInGoogleMaps
}
/>,
);
}
},
[
position
]);
return
null
;
return
createPortal
(
<
ControlButtons
position=
{
position
}
onZoomIn=
{
handleZoomIn
}
onZoomOut=
{
handleZoomOut
}
onOpenGoogleMaps=
{
handleOpenInGoogleMaps
}
/>,
container
,
);
};
};
const
MapCleanup
=
()
=>
{
const
MapCleanup
=
()
=>
{
...
@@ -222,21 +208,30 @@ const DEFAULT_CENTER_LAT_LNG = new LatLng(48.8584, 2.2945);
...
@@ -222,21 +208,30 @@ const DEFAULT_CENTER_LAT_LNG = new LatLng(48.8584, 2.2945);
const
LeafletMap
=
(
props
:
MapProps
)
=>
{
const
LeafletMap
=
(
props
:
MapProps
)
=>
{
const
position
=
props
.
latlng
||
DEFAULT_CENTER_LAT_LNG
;
const
position
=
props
.
latlng
||
DEFAULT_CENTER_LAT_LNG
;
const
statusLabel
=
props
.
readonly
?
"Pinned location"
:
props
.
latlng
?
"Selected location"
:
"Choose a location"
;
return
(
return
(
<
MapContainer
<
div
className=
"memo-location-map relative isolate w-full overflow-hidden rounded-xl border border-border bg-background shadow-sm"
>
className=
"w-full h-72"
<
MapContainer
center=
{
position
}
className=
"h-72 w-full"
zoom=
{
13
}
center=
{
position
}
scrollWheelZoom=
{
false
}
zoom=
{
13
}
zoomControl=
{
false
}
scrollWheelZoom=
{
false
}
attributionControl=
{
false
}
zoomControl=
{
false
}
>
attributionControl=
{
false
}
<
ThemedTileLayer
/>
>
<
LocationMarker
position=
{
position
}
readonly=
{
props
.
readonly
}
onChange=
{
props
.
onChange
?
props
.
onChange
:
()
=>
{}
}
/>
<
ThemedTileLayer
/>
<
MapControls
position=
{
props
.
latlng
}
/>
<
LocationMarker
position=
{
position
}
readonly=
{
props
.
readonly
}
onChange=
{
props
.
onChange
?
props
.
onChange
:
()
=>
{}
}
/>
<
MapCleanup
/>
<
MapControls
position=
{
props
.
latlng
}
/>
</
MapContainer
>
<
MapCleanup
/>
</
MapContainer
>
<
div
className=
"pointer-events-none absolute left-3 top-3 z-[450] flex items-center gap-2"
>
<
div
className=
"rounded-full border border-border bg-background/92 px-2.5 py-1 text-[11px] font-medium tracking-[0.02em] text-foreground/80 shadow-sm backdrop-blur-sm"
>
{
statusLabel
}
</
div
>
</
div
>
</
div
>
);
);
};
};
...
...
web/src/components/map/map-utils.tsx
View file @
894b3eb0
...
@@ -7,7 +7,7 @@ import { useAuth } from "@/contexts/AuthContext";
...
@@ -7,7 +7,7 @@ import { useAuth } from "@/contexts/AuthContext";
import
{
resolveTheme
}
from
"@/utils/theme"
;
import
{
resolveTheme
}
from
"@/utils/theme"
;
const
TILE_URLS
=
{
const
TILE_URLS
=
{
light
:
"https://{s}.
tile.openstreetmap.org/{z}/{x}/{y
}.png"
,
light
:
"https://{s}.
basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r
}.png"
,
dark
:
"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
,
dark
:
"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
,
}
as
const
;
}
as
const
;
...
@@ -24,12 +24,17 @@ interface MarkerIconOptions {
...
@@ -24,12 +24,17 @@ interface MarkerIconOptions {
}
}
export
const
createMarkerIcon
=
(
options
?:
MarkerIconOptions
):
DivIcon
=>
{
export
const
createMarkerIcon
=
(
options
?:
MarkerIconOptions
):
DivIcon
=>
{
const
{
fill
=
"
orange
"
,
size
=
28
,
className
=
""
}
=
options
||
{};
const
{
fill
=
"
var(--primary)
"
,
size
=
28
,
className
=
""
}
=
options
||
{};
return
new
DivIcon
({
return
new
DivIcon
({
className
:
"relative border-none"
,
className
:
"relative border-none
bg-transparent
"
,
html
:
ReactDOMServer
.
renderToString
(
html
:
ReactDOMServer
.
renderToString
(
<
MapPinIcon
className=
{
`absolute bottom-1/2 -left-1/2 ${className}`
.
trim
()
}
fill=
{
fill
}
size=
{
size
}
/>,
<
div
className=
{
`relative flex items-center justify-center ${className}`
.
trim
()
}
>
<
MapPinIcon
fill=
{
fill
}
size=
{
size
}
strokeWidth=
{
1.9
}
style=
{
{
filter
:
"drop-shadow(0 6px 10px rgba(15, 23, 42, 0.22))"
}
}
/>
</
div
>,
),
),
iconSize
:
[
size
+
8
,
size
+
8
],
iconAnchor
:
[(
size
+
8
)
/
2
,
size
+
4
],
popupAnchor
:
[
0
,
-
(
size
*
0.7
)],
});
});
};
};
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment