feat: add custom renderer for roadmaps (#5691)

* wip

* fix: update packages

* wip

* wip

* feat: editor content generator

* fix: add dimensions

* feat: add renderer

* feat: add progress modal renderer

* Add API design roadmap

* Update API roadmap rendering

* fix: button click

* fix: link item

* feat: render pdf for editor roadmaps

* Add API roadmap

* Fix broken link of full-stack roadmap

* Update content dir

* Fix typos in api roadmap

* Add assets for pdf and svg

* Add content for api roadmap

* Add todo

* fix: close on editor roadmap select

* Update link not working

* Add api roadmap to get-started and roadmaps page

---------

Co-authored-by: Arik Chakma <arikchangma@gmail.com>
This commit is contained in:
Kamran AhmedandArik Chakma authored and GitHub committed 2024-05-17 17:28:24 +01:00
1 parent 375ad931f7
commit 5e50ffbc30
119 files changed
+1360 -146

No files matched your search

@@ -18,6 +18,7 @@ import { GuideIcon } from '../ReactIcons/GuideIcon.tsx';
import { HomeIcon } from '../ReactIcons/HomeIcon.tsx';
import { VideoIcon } from '../ReactIcons/VideoIcon.tsx';
import { cn } from '../../lib/classname.ts';
import type { AllowedRoadmapRenderer } from '../../lib/roadmap.ts';
export type PageType = {
id: string;
@@ -27,6 +28,7 @@ export type PageType = {
icon?: ReactElement;
isProtected?: boolean;
metadata?: Record<string, any>;
renderer?: AllowedRoadmapRenderer;
};
const defaultPages: PageType[] = [
@@ -24,6 +24,7 @@ export type TeamResourceConfig = {
topics?: number;
sharedTeamMemberIds: string[];
sharedFriendIds: string[];
defaultRoadmapId?: string;
}[];
type RoadmapSelectorProps = {
@@ -106,6 +107,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
}
pageProgressMessage.set(`Adding roadmap to team`);
const renderer = allRoadmaps.find((r) => r.id === roadmapId)?.renderer;
const { error, response } = await httpPut<TeamResourceConfig>(
`${
import.meta.env.PUBLIC_API_URL
@@ -115,6 +117,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
resourceId: roadmapId,
resourceType: 'roadmap',
removed: [],
renderer: renderer || 'balsamiq',
},
);
@@ -124,6 +127,9 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
}
setTeamResources(response);
if (renderer === 'editor') {
setShowSelectRoadmapModal(false);
}
}
useEffect(() => {
@@ -0,0 +1,85 @@
import { useEffect, useState, type CSSProperties } from 'react';
import {
EditorRoadmapRenderer,
type RoadmapRendererProps,
} from './EditorRoadmapRenderer';
import { Spinner } from '../ReactIcons/Spinner';
import type { ResourceType } from '../../lib/resource-progress';
import { httpGet } from '../../lib/http';
import { ProgressNudge } from '../FrameRenderer/ProgressNudge';
type EditorRoadmapProps = {
resourceId: string;
resourceType?: ResourceType;
dimensions: {
width: number;
height: number;
};
};
export function EditorRoadmap(props: EditorRoadmapProps) {
const { resourceId, resourceType = 'roadmap', dimensions } = props;
const [isLoading, setIsLoading] = useState(true);
const [roadmapData, setRoadmapData] = useState<
Omit<RoadmapRendererProps, 'resourceId'> | undefined
>(undefined);
const loadRoadmapData = async () => {
setIsLoading(true);
const { response, error } = await httpGet<
Omit<RoadmapRendererProps, 'resourceId'>
>(`/${resourceId}.json`);
if (error) {
console.error(error);
return;
}
setRoadmapData(response);
setIsLoading(false);
};
useEffect(() => {
loadRoadmapData().finally();
}, [resourceId]);
if (!roadmapData || isLoading) {
return (
<div
style={
{
'--aspect-ratio': dimensions.width / dimensions.height,
} as CSSProperties
}
className="flex aspect-[var(--aspect-ratio)] w-full justify-center"
>
<div className="flex w-full justify-center">
<Spinner
innerFill="#2563eb"
outerFill="#E5E7EB"
className="h-6 w-6 animate-spin sm:h-12 sm:w-12"
/>
</div>
</div>
);
}
return (
<div
style={
{
'--aspect-ratio': dimensions.width / dimensions.height,
} as CSSProperties
}
className="flex aspect-[var(--aspect-ratio)] w-full justify-center"
>
<EditorRoadmapRenderer
{...roadmapData}
dimensions={dimensions}
resourceId={resourceId}
/>
<ProgressNudge resourceId={resourceId} resourceType={resourceType} />
</div>
);
}
@@ -0,0 +1,56 @@
svg text tspan {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeSpeed;
}
svg > g[data-type='topic'],
svg > g[data-type='subtopic'],
svg g[data-type='link-item'],
svg > g[data-type='button'] {
cursor: pointer;
}
svg > g[data-type='topic']:hover > rect {
fill: #d6d700;
}
svg > g[data-type='subtopic']:hover > rect {
fill: #f3c950;
}
svg > g[data-type='button']:hover {
opacity: 0.8;
}
svg g[data-type='link-item']:hover {
opacity: 0.8;
}
svg .done rect {
fill: #cbcbcb !important;
}
svg .done text,
svg .skipped text {
text-decoration: line-through;
}
svg > g[data-type='topic'].learning > rect + text,
svg > g[data-type='topic'].done > rect + text {
fill: black;
}
svg > g[data-type='subtipic'].done > rect + text,
svg > g[data-type='subtipic'].learning > rect + text {
fill: #cbcbcb;
}
svg .learning rect {
fill: #dad1fd !important;
}
svg .learning text {
text-decoration: underline;
}
svg .skipped rect {
fill: #496b69 !important;
}
@@ -0,0 +1,176 @@
import { useCallback, useEffect, useRef } from 'react';
import './EditorRoadmapRenderer.css';
import {
renderResourceProgress,
updateResourceProgress,
type ResourceProgressType,
renderTopicProgress,
refreshProgressCounters,
} from '../../lib/resource-progress';
import { pageProgressMessage } from '../../stores/page';
import { useToast } from '../../hooks/use-toast';
import type { Edge, Node } from 'reactflow';
import { Renderer } from '../../../editor/renderer';
import { slugify } from '../../lib/slugger';
export type RoadmapRendererProps = {
resourceId: string;
nodes: Node[];
edges: Edge[];
dimensions: {
width: number;
height: number;
};
};
type RoadmapNodeDetails = {
nodeId: string;
nodeType: string;
targetGroup: SVGElement;
title?: string;
};
function getNodeDetails(svgElement: SVGElement): RoadmapNodeDetails | null {
const targetGroup = (svgElement?.closest('g') as SVGElement) || {};
const nodeId = targetGroup?.dataset?.nodeId;
const nodeType = targetGroup?.dataset?.type;
const title = targetGroup?.dataset?.title;
if (!nodeId || !nodeType) {
return null;
}
return { nodeId, nodeType, targetGroup, title };
}
const allowedNodeTypes = ['topic', 'subtopic', 'button', 'link-item'];
export function EditorRoadmapRenderer(props: RoadmapRendererProps) {
const { resourceId, nodes = [], edges = [] } = props;
const roadmapRef = useRef<HTMLDivElement>(null);
const toast = useToast();
async function updateTopicStatus(
topicId: string,
newStatus: ResourceProgressType,
) {
pageProgressMessage.set('Updating progress');
updateResourceProgress(
{
resourceId,
resourceType: 'roadmap',
topicId,
},
newStatus,
)
.then(() => {
renderTopicProgress(topicId, newStatus);
})
.catch((err) => {
toast.error('Something went wrong, please try again.');
console.error(err);
})
.finally(() => {
pageProgressMessage.set('');
refreshProgressCounters();
});
return;
}
const handleSvgClick = useCallback((e: MouseEvent) => {
const target = e.target as SVGElement;
const { nodeId, nodeType, targetGroup, title } =
getNodeDetails(target) || {};
if (!nodeId || !nodeType || !allowedNodeTypes.includes(nodeType)) {
return;
}
if (nodeType === 'button' || nodeType === 'link-item') {
const link = targetGroup?.dataset?.link || '';
const isExternalLink = link.startsWith('http');
if (isExternalLink) {
window.open(link, '_blank');
} else {
window.location.href = link;
}
return;
}
const isCurrentStatusLearning = targetGroup?.classList.contains('learning');
const isCurrentStatusSkipped = targetGroup?.classList.contains('skipped');
if (e.shiftKey) {
e.preventDefault();
updateTopicStatus(
nodeId,
isCurrentStatusLearning ? 'pending' : 'learning',
);
return;
} else if (e.altKey) {
e.preventDefault();
updateTopicStatus(nodeId, isCurrentStatusSkipped ? 'pending' : 'skipped');
return;
}
if (!title) {
return;
}
const detailsPattern = `${slugify(title)}@${nodeId}`;
window.dispatchEvent(
new CustomEvent('roadmap.node.click', {
detail: {
topicId: detailsPattern,
resourceId,
resourceType: 'roadmap',
},
}),
);
}, []);
const handleSvgRightClick = useCallback((e: MouseEvent) => {
e.preventDefault();
const target = e.target as SVGElement;
const { nodeId, nodeType, targetGroup } = getNodeDetails(target) || {};
if (!nodeId || !nodeType || !allowedNodeTypes.includes(nodeType)) {
return;
}
if (nodeType === 'button') {
return;
}
const isCurrentStatusDone = targetGroup?.classList.contains('done');
updateTopicStatus(nodeId, isCurrentStatusDone ? 'pending' : 'done');
}, []);
useEffect(() => {
if (!roadmapRef?.current) {
return;
}
roadmapRef?.current?.addEventListener('click', handleSvgClick);
roadmapRef?.current?.addEventListener('contextmenu', handleSvgRightClick);
return () => {
roadmapRef?.current?.removeEventListener('click', handleSvgClick);
roadmapRef?.current?.removeEventListener(
'contextmenu',
handleSvgRightClick,
);
};
}, []);
return (
<Renderer
ref={roadmapRef}
roadmap={{ nodes, edges }}
onRendered={() => {
roadmapRef.current?.setAttribute('data-renderer', 'editor');
renderResourceProgress('roadmap', resourceId).finally();
}}
/>
);
}
@@ -52,7 +52,7 @@ svg .done rect {
fill: #cbcbcb !important;
}
svg .done rect[stroke="rgb(255,229,153)"] {
svg .done rect[stroke='rgb(255,229,153)'] {
stroke: #cbcbcb !important;
}
@@ -133,10 +133,12 @@ svg .removed path {
}
}
#customized-roadmap #resource-svg-wrap g:not([class]),
#customized-roadmap #resource-svg-wrap circle,
#customized-roadmap #resource-svg-wrap path[stroke='#fff'],
#customized-roadmap #resource-svg-wrap g[data-group-id$='-note'] {
#customized-roadmap #resource-svg-wrap:not([data-renderer]) g:not([class]),
#customized-roadmap #resource-svg-wrap:not([data-renderer]) circle,
#customized-roadmap #resource-svg-wrap:not([data-renderer]) path[stroke='#fff'],
#customized-roadmap
#resource-svg-wrap:not([data-renderer])
g[data-group-id$='-note'] {
display: none;
}
+13 -10
View File
@@ -7,12 +7,14 @@ import { useToast } from '../../hooks/use-toast';
import { TrashIcon } from '../ReactIcons/TrashIcon';
import { AddedUserIcon } from '../ReactIcons/AddedUserIcon';
import { AddUserIcon } from '../ReactIcons/AddUserIcon';
import type { AllowedRoadmapRenderer } from '../../lib/roadmap';
type FriendProgressItemProps = {
friend: ListFriendsResponse[0];
onShowResourceProgress: (
resourceId: string,
isCustomResource?: boolean
isCustomResource?: boolean,
renderer?: AllowedRoadmapRenderer,
) => void;
onReload: () => void;
};
@@ -27,7 +29,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
pageProgressMessage.set('Please wait...');
const { response, error } = await httpDelete(
`${import.meta.env.PUBLIC_API_URL}/v1-delete-friend/${userId}`,
{}
{},
);
if (error || !response) {
@@ -43,7 +45,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
pageProgressMessage.set('Please wait...');
const { response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-add-friend/${userId}`,
{}
{},
);
if (error || !response) {
@@ -92,7 +94,8 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
onClick={() =>
onShowResourceProgress(
progress.resourceId,
progress.isCustomResource
progress.isCustomResource,
progress?.renderer,
)
}
className="group relative overflow-hidden rounded-md border p-2 hover:border-gray-300 hover:text-black focus:outline-none"
@@ -160,7 +163,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
deleteFriend(friend.userId, 'Friend removed').finally(
() => {
pageProgressMessage.set('');
}
},
);
}}
>
@@ -198,7 +201,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
addFriend(friend.userId, 'Friend request accepted').finally(
() => {
pageProgressMessage.set('');
}
},
);
}}
>
@@ -225,7 +228,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
deleteFriend(friend.userId, 'Friend request removed').finally(
() => {
pageProgressMessage.set('');
}
},
);
}}
>
@@ -267,7 +270,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
onClick={() => {
deleteFriend(
friend.userId,
'Friend request withdrawn'
'Friend request withdrawn',
).finally(() => {
pageProgressMessage.set('');
});
@@ -304,7 +307,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
addFriend(friend.userId, 'Friend request accepted').finally(
() => {
pageProgressMessage.set('');
}
},
);
}}
className="mb-1 block w-full max-w-[150px] rounded-md bg-black py-1.5 text-sm text-white"
@@ -316,7 +319,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
onClick={() => {
deleteFriend(
friend.userId,
'Friend request rejected'
'Friend request rejected',
).finally(() => {
pageProgressMessage.set('');
});
+12 -3
View File
@@ -11,6 +11,7 @@ import { UserProgressModal } from '../UserProgress/UserProgressModal';
import { InviteFriendPopup } from './InviteFriendPopup';
import { UserCustomProgressModal } from '../UserProgress/UserCustomProgressModal';
import { UserIcon } from 'lucide-react';
import type { AllowedRoadmapRenderer } from '../../lib/roadmap';
type FriendResourceProgress = {
updatedAt: string;
@@ -22,6 +23,7 @@ type FriendResourceProgress = {
skipped: number;
done: number;
total: number;
renderer?: AllowedRoadmapRenderer;
};
export type ListFriendsResponse = {
@@ -55,6 +57,7 @@ export function FriendsPage() {
resourceId: string;
friend: ListFriendsResponse[0];
isCustomResource?: boolean;
renderer?: AllowedRoadmapRenderer;
}>();
const [isLoading, setIsLoading] = useState(true);
@@ -92,8 +95,8 @@ export function FriendsPage() {
(grouping) => grouping.value === selectedGrouping,
);
const filteredFriends = friends.filter(
(friend) => selectedGroupingType?.statuses.includes(friend.status),
const filteredFriends = friends.filter((friend) =>
selectedGroupingType?.statuses.includes(friend.status),
);
const receivedRequests = friends.filter(
@@ -124,6 +127,7 @@ export function FriendsPage() {
resourceType={'roadmap'}
onClose={() => setShowFriendProgress(undefined)}
isCustomResource={showFriendProgress?.isCustomResource}
renderer={showFriendProgress?.renderer}
/>
);
@@ -182,11 +186,16 @@ export function FriendsPage() {
{filteredFriends.map((friend) => (
<FriendProgressItem
friend={friend}
onShowResourceProgress={(resourceId, isCustomResource) => {
onShowResourceProgress={(
resourceId,
isCustomResource,
renderer,
) => {
setShowFriendProgress({
resourceId,
friend,
isCustomResource,
renderer,
});
}}
key={friend.userId}
+10 -5
View File
@@ -1,6 +1,6 @@
---
import { getQuestionGroupsByIds } from '../lib/question-group';
import { getRoadmapsByIds, RoadmapFrontmatter } from '../lib/roadmap';
import { getRoadmapsByIds, type RoadmapFrontmatter } from '../lib/roadmap';
import { Map, Clipboard } from 'lucide-react';
export interface Props {
@@ -59,14 +59,19 @@ const relatedQuestionDetails = await getQuestionGroupsByIds(relatedQuestions);
{
relatedRoadmaps.length && (
<div class:list={['border-t bg-gray-100', {
'mt-8': !relatedQuestionDetails.length
}]}>
<div
class:list={[
'border-t bg-gray-100',
{
'mt-8': !relatedQuestionDetails.length,
},
]}
>
<div class='container'>
<div class='relative -top-5 flex justify-between'>
<span class='text-md flex items-center rounded-md border bg-white px-3 py-1 font-medium'>
<Map className='text-black mr-1.5' size='17px' />
Related <span class="hidden sm:inline">Roadmaps</span>
Related <span class='hidden sm:inline'>Roadmaps</span>
</span>
<a
href='/roadmaps'
+2 -2
View File
@@ -23,7 +23,7 @@ const hasTnsBanner = !!tnsBannerLink;
<div
class:list={[
'mt-4 sm:mt-7 border-0 sm:border rounded-md mb-0 bg-white',
'mb-0 mt-4 rounded-md border-0 bg-white sm:mt-7 sm:border',
...(hasTnsBanner
? [
{
@@ -42,7 +42,7 @@ const hasTnsBanner = !!tnsBannerLink;
<ResourceProgressStats
resourceId={roadmapId}
resourceType='roadmap'
hasSecondaryBanner={hasTitleQuestion}
hasSecondaryBanner={Boolean(hasTitleQuestion)}
/>
{
+6
View File
@@ -77,6 +77,12 @@ const groups: GroupType[] = [
type: 'role',
otherGroups: ['Web Development', 'Absolute Beginners'],
},
{
title: 'API Design',
link: '/api',
type: 'role',
otherGroups: ['Web Development'],
},
{
title: 'QA',
link: '/qa',
@@ -189,10 +189,10 @@ export function TeamActivityPage() {
Team Activity
</h3>
<ul className="mb-4 mt-2 flex flex-col gap-3">
{usersWithActivities.map((user) => {
{usersWithActivities.map((user, index) => {
return (
<TeamActivityItem
key={user._id}
key={`${user._id}-${index}`}
user={user}
teamId={teamId}
onTopicClick={setSelectedActivity}
@@ -15,6 +15,7 @@ import type { ResourceType } from '../../lib/resource-progress';
import { MemberProgressModal } from '../TeamProgress/MemberProgressModal';
import { useStore } from '@nanostores/react';
import { $currentTeam } from '../../stores/team';
import { MemberCustomProgressModal } from '../TeamProgress/MemberCustomProgressModal';
type GetTeamMemberProgressesResponse = TeamMemberDocument & {
name: string;
@@ -99,10 +100,15 @@ export function TeamMemberDetailsPage() {
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${memberProgress?.avatar}`
: '/images/default-avatar.png';
const ProgressModal =
selectedResource && !selectedResource.isCustomResource
? MemberProgressModal
: MemberCustomProgressModal;
return (
<>
{selectedResource && (
<MemberProgressModal
<ProgressModal
teamId={teamId}
member={{
...memberProgress,
@@ -183,7 +189,11 @@ export function TeamMemberDetailsPage() {
memberActivity?.data?.flatMap((act) => act.activity) || []
}
onResourceClick={(resourceId, resourceType, isCustomResource) => {
setSelectedResource({ resourceId, resourceType, isCustomResource });
setSelectedResource({
resourceId,
resourceType,
isCustomResource,
});
}}
/>
<Pagination
@@ -147,6 +147,7 @@ export function TeamRoadmaps() {
toast.loading('Adding roadmap');
pageProgressMessage.set('Adding roadmap');
setIsLoading(true);
const roadmap = allRoadmaps.find((r) => r.id === roadmapId);
const { error, response } = await httpPut<TeamResourceConfig>(
`${
import.meta.env.PUBLIC_API_URL
@@ -156,6 +157,7 @@ export function TeamRoadmaps() {
resourceId: roadmapId,
resourceType: 'roadmap',
removed: [],
renderer: roadmap?.renderer || 'balsamiq',
},
);
@@ -166,6 +168,9 @@ export function TeamRoadmaps() {
setTeamResources(response);
toast.success('Roadmap added');
if (roadmap?.renderer === 'editor') {
setIsAddingRoadmap(false);
}
}
async function onRemove(resourceId: string) {
@@ -219,11 +224,14 @@ export function TeamRoadmaps() {
/>
);
const filteredAllRoadmaps = allRoadmaps.filter(
(r) => !teamResources.find((c) => c?.defaultRoadmapId === r.id),
);
const addRoadmapModal = isAddingRoadmap && (
<SelectRoadmapModal
onClose={() => setIsAddingRoadmap(false)}
teamResourceConfig={teamResources}
allRoadmaps={allRoadmaps}
allRoadmaps={filteredAllRoadmaps}
teamId={teamId}
onRoadmapAdd={(roadmapId: string) => {
onAdd(roadmapId).finally(() => {
@@ -106,6 +106,10 @@ export function TeamVersions(props: TeamVersionsProps) {
}, []);
useEffect(() => {
if (!selectedTeamVersion) {
return;
}
clearResourceProgress();
// teams have customizations. Assigning #customized-roadmap to roadmapSvgWrap
+3 -2
View File
@@ -225,11 +225,12 @@ export function TopicDetail(props: TopicDetailProps) {
className="fixed right-0 top-0 z-40 flex h-screen w-full flex-col overflow-y-auto bg-white p-4 focus:outline-0 sm:max-w-[600px] sm:p-6"
>
{isLoading && (
<div className="flex w-full justify-center">
<div className="flex w-full h-full items-center justify-center">
<Spinner
outerFill="#d1d5db"
className="h-6 w-6 sm:h-12 sm:w-12"
className="h-6 w-6 sm:h-8 sm:w-8"
innerFill="#2563eb"
isDualRing={false}
/>
</div>
)}
@@ -11,6 +11,9 @@ import { useAuth } from '../../hooks/use-auth';
import { ModalLoader } from './ModalLoader.tsx';
import { UserProgressModalHeader } from './UserProgressModalHeader';
import { X } from 'lucide-react';
import type { PageType } from '../CommandMenu/CommandMenu.tsx';
import type { AllowedRoadmapRenderer } from '../../lib/roadmap.ts';
import { renderFlowJSON } from '../../../editor/renderer/renderer.ts';
export type ProgressMapProps = {
userId?: string;
@@ -18,6 +21,7 @@ export type ProgressMapProps = {
resourceType: ResourceType;
onClose?: () => void;
isCustomResource?: boolean;
renderer?: AllowedRoadmapRenderer;
};
export type UserProgressResponse = {
@@ -39,6 +43,7 @@ export function UserProgressModal(props: ProgressMapProps) {
resourceType,
userId: propUserId,
onClose: onModalClose,
renderer = 'balsamiq',
} = props;
const { s: userId = propUserId } = getUrlParams();
@@ -87,15 +92,18 @@ export function UserProgressModal(props: ProgressMapProps) {
async function getRoadmapSVG(
jsonUrl: string,
renderer: AllowedRoadmapRenderer = 'balsamiq',
): Promise<SVGElement | undefined> {
const { error, response: roadmapJson } = await httpGet(jsonUrl);
if (error || !roadmapJson) {
throw error || new Error('Something went wrong. Please try again!');
}
return await wireframeJSONToSVG(roadmapJson, {
fontURL: '/fonts/balsamiq.woff2',
});
return renderer === 'editor'
? await renderFlowJSON(roadmapJson as any)
: await wireframeJSONToSVG(roadmapJson, {
fontURL: '/fonts/balsamiq.woff2',
});
}
function onClose() {
@@ -124,8 +132,10 @@ export function UserProgressModal(props: ProgressMapProps) {
}
setIsLoading(true);
setError('');
Promise.all([
getRoadmapSVG(resourceJsonUrl),
getRoadmapSVG(resourceJsonUrl, renderer),
getUserProgress(userId, resourceType, resourceId),
])
.then(([svg, user]) => {