mirror of
https://github.com/PostHog/posthog.git
synced 2024-11-21 21:49:51 +01:00
feat: Person feed map (#18184)
This commit is contained in:
parent
8a188cc192
commit
4412c2cbeb
1
.env.example
Normal file
1
.env.example
Normal file
@ -0,0 +1 @@
|
||||
MAPLIBRE_STYLE_URL=https://api.example.com/style.json?key=mykey
|
BIN
frontend/__snapshots__/components-map--unavailable.png
Normal file
BIN
frontend/__snapshots__/components-map--unavailable.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 10 KiB |
1
frontend/src/globals.d.ts
vendored
1
frontend/src/globals.d.ts
vendored
@ -6,6 +6,7 @@ declare global {
|
||||
JS_POSTHOG_API_KEY?: string
|
||||
JS_POSTHOG_HOST?: string
|
||||
JS_POSTHOG_SELF_CAPTURE?: boolean
|
||||
JS_MAPLIBRE_STYLE_URL?: string
|
||||
JS_CAPTURE_TIME_TO_SEE_DATA?: boolean
|
||||
JS_KEA_VERBOSE_LOGGING?: boolean
|
||||
posthog?: posthog
|
||||
|
31
frontend/src/lib/components/Map/Map.stories.tsx
Normal file
31
frontend/src/lib/components/Map/Map.stories.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { Marker } from 'maplibre-gl'
|
||||
|
||||
import { Map, MapComponent } from './Map'
|
||||
|
||||
const meta: Meta<typeof Map> = {
|
||||
title: 'Components/Map',
|
||||
component: Map,
|
||||
tags: ['autodocs'],
|
||||
}
|
||||
type Story = StoryObj<typeof Map>
|
||||
|
||||
const coordinates: [number, number] = [0.119167, 52.205276]
|
||||
|
||||
export const Unavailable: Story = {}
|
||||
|
||||
export const Basic: Story = {
|
||||
render: (args) => (
|
||||
<MapComponent
|
||||
mapLibreStyleUrl="" // TODO: set this value for the publish storybook and visual regression tests
|
||||
{...args}
|
||||
/>
|
||||
),
|
||||
args: {
|
||||
center: coordinates,
|
||||
markers: [new Marker({ color: 'var(--primary)' }).setLngLat(coordinates)],
|
||||
className: 'h-60',
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
64
frontend/src/lib/components/Map/Map.tsx
Normal file
64
frontend/src/lib/components/Map/Map.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Map as RawMap, Marker } from 'maplibre-gl'
|
||||
import useResizeObserver from 'use-resize-observer'
|
||||
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
|
||||
/** Latitude and longtitude in degrees (+lat is east, -lat is west, +lon is south, -lon is north). */
|
||||
export interface MapProps {
|
||||
/** Coordinates to center the map on by default. */
|
||||
center: [number, number]
|
||||
/** Markers to show. */
|
||||
markers?: Marker[]
|
||||
/** Map container class names. */
|
||||
className?: string
|
||||
/** The map's MapLibre style. This must be a JSON object conforming to the schema described in the MapLibre Style Specification, or a URL to such JSON. */
|
||||
mapLibreStyleUrl: string
|
||||
}
|
||||
|
||||
export function Map({ className, ...rest }: Omit<MapProps, 'mapLibreStyleUrl'>): JSX.Element {
|
||||
if (!window.JS_MAPLIBRE_STYLE_URL) {
|
||||
return (
|
||||
<div className={`w-full h-full flex flex-col items-center justify-center text-muted p-3 ${className}`}>
|
||||
<h1>Map unavailable</h1>
|
||||
<p>
|
||||
The <code>MAPLIBRE_STYLE_URL</code> setting is not defined. Please configure this setting with a
|
||||
valid MapLibre Style URL to display maps.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <MapComponent mapLibreStyleUrl={window.JS_MAPLIBRE_STYLE_URL} className={className} {...rest} />
|
||||
}
|
||||
|
||||
export function MapComponent({ center, markers, className, mapLibreStyleUrl }: MapProps): JSX.Element {
|
||||
const mapContainer = useRef<HTMLDivElement>(null)
|
||||
const map = useRef<RawMap | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
map.current = new RawMap({
|
||||
container: mapContainer.current as HTMLElement,
|
||||
style: mapLibreStyleUrl,
|
||||
center,
|
||||
zoom: 4,
|
||||
maxZoom: 10,
|
||||
})
|
||||
if (markers) {
|
||||
for (const marker of markers) {
|
||||
marker.addTo(map.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useResizeObserver({
|
||||
ref: mapContainer,
|
||||
onResize: () => {
|
||||
if (map.current) {
|
||||
map.current.resize()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return <div ref={mapContainer} className={className} />
|
||||
}
|
64
frontend/src/scenes/notebooks/Nodes/NotebookNodeMap.tsx
Normal file
64
frontend/src/scenes/notebooks/Nodes/NotebookNodeMap.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { Marker } from 'maplibre-gl'
|
||||
|
||||
import { NotebookNodeType } from '~/types'
|
||||
import { createPostHogWidgetNode } from 'scenes/notebooks/Nodes/NodeWrapper'
|
||||
import { personLogic } from 'scenes/persons/personLogic'
|
||||
import { useValues } from 'kea'
|
||||
import { LemonSkeleton } from '@posthog/lemon-ui'
|
||||
import { NotFound } from 'lib/components/NotFound'
|
||||
import { Map } from '../../../lib/components/Map/Map'
|
||||
import { notebookNodeLogic } from './notebookNodeLogic'
|
||||
import { NotebookNodeProps } from 'scenes/notebooks/Notebook/utils'
|
||||
import { NotebookNodeEmptyState } from './components/NotebookNodeEmptyState'
|
||||
|
||||
const Component = ({ attributes }: NotebookNodeProps<NotebookNodeMapAttributes>): JSX.Element | null => {
|
||||
const { id } = attributes
|
||||
const { expanded } = useValues(notebookNodeLogic)
|
||||
|
||||
const logic = personLogic({ id })
|
||||
const { person, personLoading } = useValues(logic)
|
||||
|
||||
if (personLoading) {
|
||||
return <LemonSkeleton className="h-6" />
|
||||
} else if (!person) {
|
||||
return <NotFound object="person" />
|
||||
}
|
||||
|
||||
if (!expanded) {
|
||||
return null
|
||||
}
|
||||
|
||||
const longtitude = person?.properties?.['$geoip_longitude']
|
||||
const latitude = person?.properties?.['$geoip_latitude']
|
||||
const personCoordinates: [number, number] | null =
|
||||
!isNaN(longtitude) && !isNaN(latitude) ? [longtitude, latitude] : null
|
||||
|
||||
if (!personCoordinates) {
|
||||
return <NotebookNodeEmptyState message="No map available." />
|
||||
}
|
||||
|
||||
return (
|
||||
<Map
|
||||
center={personCoordinates}
|
||||
markers={[new Marker({ color: 'var(--primary)' }).setLngLat(personCoordinates)]}
|
||||
className="h-full"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type NotebookNodeMapAttributes = {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const NotebookNodeMap = createPostHogWidgetNode<NotebookNodeMapAttributes>({
|
||||
nodeType: NotebookNodeType.Map,
|
||||
titlePlaceholder: 'Location',
|
||||
Component,
|
||||
resizeable: true,
|
||||
heightEstimate: 150,
|
||||
expandable: true,
|
||||
startExpanded: true,
|
||||
attributes: {
|
||||
id: {},
|
||||
},
|
||||
})
|
@ -0,0 +1,11 @@
|
||||
type NotebookNodeEmptyStateProps = {
|
||||
message: string
|
||||
}
|
||||
|
||||
export function NotebookNodeEmptyState({ message }: NotebookNodeEmptyStateProps): JSX.Element {
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-muted p-3">
|
||||
<i>{message}</i>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -39,6 +39,7 @@ import { sampleOne } from 'lib/utils'
|
||||
import { NotebookNodeGroup } from '../Nodes/NotebookNodeGroup'
|
||||
import { NotebookNodeCohort } from '../Nodes/NotebookNodeCohort'
|
||||
import { NotebookNodePersonFeed } from '../Nodes/NotebookNodePersonFeed/NotebookNodePersonFeed'
|
||||
import { NotebookNodeMap } from '../Nodes/NotebookNodeMap'
|
||||
|
||||
const CustomDocument = ExtensionDocument.extend({
|
||||
content: 'heading block*',
|
||||
@ -120,6 +121,7 @@ export function Editor(): JSX.Element {
|
||||
BacklinkCommandsExtension,
|
||||
NodeGapInsertionExtension,
|
||||
NotebookNodePersonFeed,
|
||||
NotebookNodeMap,
|
||||
],
|
||||
editorProps: {
|
||||
handleDrop: (view, event, _slice, moved) => {
|
||||
|
@ -125,6 +125,7 @@ export const textContent = (node: any): string => {
|
||||
'ph-group': customOrTitleSerializer,
|
||||
'ph-cohort': customOrTitleSerializer,
|
||||
'ph-person-feed': customOrTitleSerializer,
|
||||
'ph-map': customOrTitleSerializer,
|
||||
}
|
||||
|
||||
return getText(node, {
|
||||
|
@ -4,7 +4,7 @@ import { NotebooksListFilters } from 'scenes/notebooks/NotebooksTable/notebooksT
|
||||
|
||||
export const fromNodeTypeToLabel: Omit<
|
||||
Record<NotebookNodeType, string>,
|
||||
NotebookNodeType.Backlink | NotebookNodeType.PersonFeed
|
||||
NotebookNodeType.Backlink | NotebookNodeType.PersonFeed | NotebookNodeType.Map
|
||||
> = {
|
||||
[NotebookNodeType.FeatureFlag]: 'Feature flags',
|
||||
[NotebookNodeType.FeatureFlagCodeExample]: 'Feature flag Code Examples',
|
||||
|
@ -32,6 +32,10 @@ const PersonFeedCanvas = ({ person }: PersonFeedCanvasProps): JSX.Element => {
|
||||
type: 'ph-person',
|
||||
attrs: { id: personId, nodeId: uuid(), title: 'Info' },
|
||||
},
|
||||
{
|
||||
type: 'ph-map',
|
||||
attrs: { id: personId, nodeId: uuid() },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
@ -3082,6 +3082,7 @@ export enum NotebookNodeType {
|
||||
ReplayTimestamp = 'ph-replay-timestamp',
|
||||
Image = 'ph-image',
|
||||
PersonFeed = 'ph-person-feed',
|
||||
Map = 'ph-map',
|
||||
}
|
||||
|
||||
export type NotebookNodeResource = {
|
||||
|
@ -130,6 +130,7 @@
|
||||
"kea-test-utils": "^0.2.4",
|
||||
"kea-waitfor": "^0.2.1",
|
||||
"kea-window-values": "^3.0.0",
|
||||
"maplibre-gl": "^3.5.1",
|
||||
"md5": "^2.3.0",
|
||||
"monaco-editor": "^0.39.0",
|
||||
"papaparse": "^5.4.1",
|
||||
|
723
pnpm-lock.yaml
723
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -79,6 +79,9 @@ CAPTURE_TIME_TO_SEE_DATA = get_from_env("CAPTURE_TIME_TO_SEE_DATA", False, type_
|
||||
# Whether kea should be act in verbose mode
|
||||
KEA_VERBOSE_LOGGING = get_from_env("KEA_VERBOSE_LOGGING", False, type_cast=str_to_bool)
|
||||
|
||||
# MapLibre Style URL to configure map tile source
|
||||
MAPLIBRE_STYLE_URL = get_from_env("MAPLIBRE_STYLE_URL", optional=True)
|
||||
|
||||
# Only written in specific scripts - do not use outside of them.
|
||||
PERSON_ON_EVENTS_OVERRIDE = get_from_env("PERSON_ON_EVENTS_OVERRIDE", optional=True, type_cast=str_to_bool)
|
||||
|
||||
|
@ -36,6 +36,11 @@
|
||||
window.SENTRY_ENVIRONMENT = '{{ sentry_environment | escapejs }}';
|
||||
</script>
|
||||
{% endif %}
|
||||
{% if js_maplibre_style_url %}
|
||||
<script>
|
||||
window.JS_MAPLIBRE_STYLE_URL = '{{ js_maplibre_style_url | escapejs }}'
|
||||
</script>
|
||||
{% endif %}
|
||||
<script id='posthog-app-user-preload'>
|
||||
window.POSTHOG_APP_CONTEXT = JSON.parse("{{ posthog_app_context | escapejs }}");
|
||||
// Inject the expected location of JS bundle, use to allow the location of
|
||||
|
@ -332,6 +332,9 @@ def render_template(
|
||||
context["js_kea_verbose_logging"] = settings.KEA_VERBOSE_LOGGING
|
||||
context["js_url"] = get_js_url(request)
|
||||
|
||||
if settings.MAPLIBRE_STYLE_URL:
|
||||
context["js_maplibre_style_url"] = settings.MAPLIBRE_STYLE_URL
|
||||
|
||||
posthog_app_context: Dict[str, Any] = {
|
||||
"persisted_feature_flags": settings.PERSISTED_FEATURE_FLAGS,
|
||||
"anonymous": not request.user or not request.user.is_authenticated,
|
||||
|
Loading…
Reference in New Issue
Block a user