Skip to content

Commit

Permalink
Feat/sigma network visualization (#13)
Browse files Browse the repository at this point in the history
this adds a new network visualisation page, and also adds a network
visualisation tab to entity details pages.

the visualisation uses [sigma.js](https://www.sigmajs.org/) for
rendering the graph on a webgl canvas, and
[graphology](https://graphology.github.io/) for the graph layout.
  • Loading branch information
oliviareichl authored May 16, 2024
1 parent 747e667 commit bb1ac5a
Showing 23 changed files with 2,538 additions and 1,711 deletions.
1 change: 1 addition & 0 deletions components/app-header.vue
Original file line number Diff line number Diff line change
@@ -14,6 +14,7 @@ const defaultLinks = computed(() => {
home: { href: { path: "/" }, label: t("AppHeader.links.home") },
data: { href: { path: "/data" }, label: t("AppHeader.links.data") },
map: { href: { path: "/map" }, label: t("AppHeader.links.map") },
network: { href: { path: "/network" }, label: t("AppHeader.links.network") },
team: { href: { path: "/team" }, label: t("AppHeader.links.team") },
} satisfies Record<string, { href: NavLinkProps["href"]; label: string }>;
});
61 changes: 61 additions & 0 deletions components/data-graph.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<script setup lang="ts">
import Graph from "graphology";
import { DotIcon } from "lucide-vue-next";
import type { NetworkEntity } from "@/types/api";
import { colors } from "../project.config.json";
const props = defineProps<{
networkData: NetworkEntity;
searchNode: string;
}>();
const graph = new Graph();
const { entityColors } = colors;
const defaultColor = colors.entityDefaultColor;
watch(
() => {
return props.networkData;
},
(networkData) => {
/** Clear previous graph data. */
graph.clear();
if (networkData.length === 0) return;
/** Add all nodes. */
networkData.forEach((entity) => {
if (!graph.hasNode(entity.id) && entity.systemClass) {
graph.addNode(entity.id, {
label: entity.label,
color: getNodeColor(entity.systemClass),
size: networkConfig.sourceNodeSize,
});
}
});
//** Add edges. */
networkData.forEach((entity) => {
entity.relations.forEach((element) => {
if (!graph.hasEdge(entity.id, element)) {
graph.addEdge(entity.id, element);
}
});
});
},
{ immediate: true },
);
function getNodeColor(nodeClass: string) {
//@ts-expect-error: no error occurs
return entityColors[nodeClass] ?? defaultColor;
}
</script>

<template>
<div class="absolute z-10 m-3 flex w-full"></div>
<Network v-if="graph.size > 0" :graph="graph" :search-node="props.searchNode" />
</template>
1 change: 0 additions & 1 deletion components/data-map-view.vue
Original file line number Diff line number Diff line change
@@ -7,7 +7,6 @@ import { z } from "zod";
import type { SearchFormData } from "@/components/search-form.vue";
import type { EntityFeature } from "@/composables/use-create-entity";
import { categories } from "@/composables/use-get-search-results";
import { project } from "@/config/project.config";
import type { GeoJsonFeature } from "@/utils/create-geojson-feature";
const router = useRouter();
102 changes: 102 additions & 0 deletions components/data-network-view.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<script lang="ts" setup>
import { z } from "zod";
import type { SearchFormData as CategoryFormData } from "@/components/network-legend-panel.vue";
import type { SearchFormData } from "@/components/network-search-form.vue";
const router = useRouter();
const route = useRoute();
const t = useTranslations();
const searchFiltersSchema = z.object({
search: z.string().catch(""),
});
const searchFilters = computed(() => {
return searchFiltersSchema.parse(route.query);
});
type SearchFilters = z.infer<typeof searchFiltersSchema>;
function onChangeSearchFilters(values: SearchFormData) {
const query = { ...searchFilters.value, ...values };
if (values.search === "") {
// @ts-expect-error Fix me later please
delete query.search;
}
void router.push({ query });
}
function onChangeCategory(values: CategoryFormData) {
void router.push({ query: { ...searchFilters.value, ...values } });
}
const { data, error, isPending, isPlaceholderData, suspense } = useGetNetworkData(
computed(() => {
return {
exclude_system_classes: [
"type",
"object_location",
"reference_system",
"file",
"source_translation",
"source",
"bibliography",
"external_reference",
"administrative_unit",
"edition",
"type_tools",
],
};
}),
);
const isLoading = computed(() => {
return isPending.value || isPlaceholderData.value;
});
const entities = computed(() => {
return (
data.value?.results.flatMap((result) => {
return result;
}) ?? []
);
});
const systemClasses = computed(() => {
const systemClasses: Array<string> = [];
entities.value.forEach((entity) => {
if (!systemClasses.includes(entity.systemClass)) {
systemClasses.push(entity.systemClass);
}
});
return systemClasses;
});
</script>

<template>
<div class="relative grid grid-rows-[auto_1fr] gap-4">
<NetworkSearchForm :search="searchFilters.search" @submit="onChangeSearchFilters" />

<VisualisationContainer
v-slot="{ height, width }"
class="border"
:class="{ 'opacity-50 grayscale': isLoading }"
>
<NetworkLegendPanel
v-if="height && width"
class="absolute bottom-0 right-0 z-10 m-3"
:system-classes="systemClasses"
@submit="onChangeCategory"
/>
<DataGraph :network-data="entities" :search-node="searchFilters.search" />
<Centered v-if="isLoading" class="pointer-events-none">
<LoadingIndicator class="text-neutral-950" size="lg" />
</Centered>
</VisualisationContainer>
</div>
</template>
94 changes: 94 additions & 0 deletions components/entity-data-graph.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<script setup lang="ts">
import Graph from "graphology";
import circularpack from "graphology-layout/circlepack";
import { DotIcon } from "lucide-vue-next";
import type { EntityFeature } from "@/composables/use-create-entity";
import { networkConfig } from "@/config/network-visualisation.config";
import { colors } from "@/project.config.json";
const props = defineProps<{
networkData: EntityFeature;
id: number;
}>();
const { getUnprefixedId } = useIdPrefix();
const graph = new Graph();
const { entityColors } = colors;
const defaultColor = project.colors.entityDefaultColor;
const legendEntities: [string | undefined] = [""];
watch(
() => {
return props.networkData;
},
(networkData) => {
/** Clear previous graph data. */
graph.clear();
/** Add source node. */
graph.addNode(props.id, {
label: networkData.properties.title,
color: getNodeColor(networkData.systemClass),
size: networkConfig.sourceNodeSize,
});
/** Add source node to agenda of nodes */
legendEntities.push(networkData.systemClass);
/** Add relations to target nodes. */
networkData.relations?.forEach((element) => {
if (element.relationTo == null) return;
const relationId = getUnprefixedId(element.relationTo);
const nodeClass = element.relationSystemClass;
if (nodeClass == null) return;
if (!legendEntities.includes(nodeClass)) {
legendEntities.push(nodeClass);
}
graph.addNode(relationId, {
label: element.label,
color: getNodeColor(nodeClass),
size: networkConfig.relationNodeSize,
url: element.relationTo,
});
graph.addEdge(props.id, relationId);
});
},
{ immediate: true },
);
function getNodeColor(nodeClass: string) {
//@ts-expect-error: no error occurs
return entityColors[nodeClass] ?? defaultColor;
}
</script>

<template>
<div class="absolute z-10 m-3 flex w-full">
<Card class="w-max">
<span v-for="(color, entity) in entityColors" :key="entity">
<span v-if="legendEntities.includes(entity)" class="pr-4">
<DotIcon :size="50" :color="color" class="inline-block" />
<span>{{ entity }}</span>
</span>
</span>
<span v-for="entry in legendEntities" :key="entry">
<span
v-if="entry != null && entry !== '' && !Object.keys(entityColors).includes(entry)"
class="pr-4"
>
<DotIcon :size="50" :color="defaultColor" class="inline-block" />
<span>{{ entry }}</span>
</span>
</span>
</Card>
</div>
<Network v-if="graph.size > 0" :graph="graph" />
</template>
20 changes: 20 additions & 0 deletions components/entity-network.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<script setup lang="ts">
import EntityDataGraph from "@/components/entity-data-graph.vue";
const props = defineProps<{
networkData: EntityFeature | undefined;
id: number;
}>();
</script>

<template>
<Card class="h-96 overflow-hidden">
<VisualisationContainer>
<EntityDataGraph
v-if="props.networkData != null"
:id="props.id"
:network-data="props.networkData"
/>
</VisualisationContainer>
</Card>
</template>
79 changes: 79 additions & 0 deletions components/network-legend-panel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { colors } from "@/project.config.json";
const t = useTranslations();
export interface SearchFormData {
category: string; // TODO: stricter typings
}
const props = defineProps<{
systemClasses: Array<string>;
}>();
const emit = defineEmits<{
(event: "submit", values: SearchFormData): void;
}>();
// TODO: Fix me! Implement filtering by system classes
function onSubmit(element: string) {
emit("submit", {
category: element, //Array, checkbox-group html + how do i get teh values in the submit event handler
});
}
const labels = {
place: t("SystemClassNames.place"),
source: t("SystemClassNames.source"),
person: t("SystemClassNames.person"),
group: t("SystemClassNames.group"),
move: t("SystemClassNames.move"),
event: t("SystemClassNames.event"),
activity: t("SystemClassNames.activity"),
acquisition: t("SystemClassNames.acquisition"),
feature: t("SystemClassNames.feature"),
human_remains: t("SystemClassNames.human_remains"),
stratigraphic_unit: t("SystemClassNames.stratigraphic_unit"),
artifact: t("SystemClassNames.artifact"),
file: t("SystemClassNames.file"),
type: t("SystemClassNames.type"),
object_location: t("SystemClassNames.object_location"),
bibliography: t("SystemClassNames.bibliography"),
edition: t("SystemClassNames.edition"),
administrative_unit: t("SystemClassNames.administrative_unit"),
reference_system: t("SystemClassNames.reference_system"),
source_translation: t("SystemClassNames.source_translation"),
};
const systemClassColors = colors.entityColors;
</script>

<template>
<aside
class="flex max-h-72 gap-2 overflow-y-auto overflow-x-hidden rounded-md border-2 border-transparent bg-white px-4 py-2 text-sm shadow-md"
>
<div
v-for="el in props.systemClasses"
:key="el"
class="grid grid-cols-[auto_1fr] gap-3"
:style="`color: ${systemClassColors[el] ? systemClassColors[el] : '#666'}`"
>
<div class="grid grid-cols-[auto_1fr] gap-2">
<!-- <input
:id="el"
type="checkbox"
name="systemClassCheckbox"
:style="`accent-color: ${systemClassColors[el] ? systemClassColors[el] : '#666'}`"
checked
@change="onSubmit(el)"
/> -->
<span
class="m-1.5 size-2 rounded-full"
:style="`background-color: ${systemClassColors[el] ? systemClassColors[el] : '#666'}`"
></span>
<span v-if="labels[el]">{{ labels[el] }}</span>
<span v-else> {{ el }}</span>
</div>
</div>
</aside>
</template>
Loading

0 comments on commit bb1ac5a

Please sign in to comment.