+ This tag is also a child of:{" "}
+ {this.coparents.map((tag) => tag.name).join(", ")}
+
+ ) : null}
+
+
+
+
+
+
+
+
+ Are you sure you want to remove this tag
+ relationship?
+
+
+ Child tag relationships will also be removed
+ unless they are referenced elsewhere.
+
+
+ }
+ onConfirm={() =>
+ this.props.removeNode(path.at(-2)!, id)
+ }
+ okText="Yes"
+ cancelText="No"
+ >
+
+
+
+ {children.map((node) => (
+
+ ))}
+
+ )
+ }
+}
+
+function sortByWeightThenName(a: FlatTagGraphNode, b: FlatTagGraphNode) {
+ return b.weight - a.weight || a.name.localeCompare(b.name)
+}
+
+// "node-1-2-3" -> [1, 2, 3]
+function getPath(elementId?: string): number[] {
+ if (!elementId) return []
+ return elementId.split("-").slice(1).map(Number)
+}
+
+function insertChildAndSort(
+ children: FlatTagGraphNode[] = [],
+ newNode: FlatTagGraphNode
+): FlatTagGraphNode[] {
+ return [...children, newNode].sort(sortByWeightThenName)
+}
+
+@observer
+export class TagGraphPage extends React.Component {
+ static contextType = AdminAppContext
+ context!: AdminAppContextType
+
+ constructor(props: Readonly) {
+ super(props)
+ this.handleDragEnd = this.handleDragEnd.bind(this)
+ this.setWeight = this.setWeight.bind(this)
+ this.setChild = this.setChild.bind(this)
+ this.removeNode = this.removeNode.bind(this)
+ }
+
+ @observable flatTagGraph: FlatTagGraph = {}
+ @observable rootId: number | null = null
+ @observable addTagParentId?: number
+ @observable tags: MinimalTagWithIsTopic[] = []
+
+ @computed get tagGraph(): TagGraphRoot | null {
+ if (!this.rootId) return null
+ return createTagGraph(this.flatTagGraph, this.rootId)
+ }
+
+ @computed get parentsById(): Record {
+ if (!this.tagGraph) return {}
+ const parentsById: Record = {}
+ for (const [parentId, children] of Object.entries(this.flatTagGraph)) {
+ for (const child of children) {
+ const parent = this.tags.find(
+ (tag) => tag.id === Number(parentId)
+ )
+ if (parent) {
+ if (!parentsById[child.childId]) {
+ parentsById[child.childId] = [parent]
+ } else {
+ parentsById[child.childId].push(parent)
+ }
+ }
+ }
+ }
+ return parentsById
+ }
+
+ @computed get nonAreaTags() {
+ if (!this.rootId) return []
+ const areaTags = this.flatTagGraph[this.rootId]
+ const areaTagIds = areaTags?.map((tag) => tag.childId) || []
+ return this.tags.filter((tag) => !areaTagIds.includes(tag.id))
+ }
+
+ getAllChildrenOfNode(childId: number): FlatTagGraphNode[] {
+ const allChildren: FlatTagGraphNode[] =
+ toJS(this.flatTagGraph[childId]) || []
+
+ for (const child of allChildren) {
+ if (this.flatTagGraph[child.childId]) {
+ allChildren.push(...this.flatTagGraph[child.childId])
+ }
+ }
+
+ return allChildren
+ }
+
+ @action.bound setWeight(parentId: number, childId: number, weight: number) {
+ const parent = this.flatTagGraph[parentId]
+ if (!parent) return
+ const child = parent.find((node) => node.childId === childId)
+ if (!child) return
+ child.weight = weight
+ this.flatTagGraph[parentId] = parent.sort(sortByWeightThenName)
+ }
+
+ @action.bound setChild(parentId: number, childId: number) {
+ const siblings = this.flatTagGraph[parentId]
+ const tag = this.tags.find((tag) => tag.id === childId)
+ if (!tag) return
+ const child: FlatTagGraphNode = {
+ childId: tag.id,
+ parentId: parentId,
+ name: tag.name,
+ weight: 100,
+ slug: tag.slug,
+ isTopic: tag.isTopic,
+ }
+ if (siblings) {
+ this.flatTagGraph[parentId] = insertChildAndSort(siblings, child)
+ } else {
+ this.flatTagGraph[parentId] = [child]
+ }
+ }
+
+ @action.bound removeNode(parentId: number, childId: number) {
+ const children = this.flatTagGraph[parentId]
+ if (!children) return
+
+ // Remove the child from its parent. If there are no other children, delete the record
+ const remainingChildren = this.flatTagGraph[parentId].filter(
+ (node) => node.childId !== childId
+ )
+ if (remainingChildren.length) {
+ this.flatTagGraph[parentId] = remainingChildren
+ } else {
+ delete this.flatTagGraph[parentId]
+ }
+
+ // If the child had no other parents, delete its grandchildren also
+ // i.e. if you delete the "Health" area, the subgraph (Diseases -> Cardiovascular Disease, Cancer) will also be deleted,
+ // but the subgraph (Air Pollution -> Indoor Air Pollution, Outdoor Air Pollution) won't be, because it's still a child of "Energy and Environment"
+ const hasMultipleParents = this.parentsById[childId]
+ if (!hasMultipleParents) return
+
+ const grandchildren = this.flatTagGraph[childId]
+ if (!grandchildren) return
+
+ for (const grandchild of grandchildren) {
+ this.removeNode(childId, grandchild.childId)
+ }
+ }
+
+ @action.bound handleDragEnd(event: DragEndEvent) {
+ if (!this.tagGraph) return
+
+ const activeHtmlId = event.active.id as string
+ const overHtmlId = event.over?.id as string
+ if (!activeHtmlId || !overHtmlId) return
+
+ const childPath = getPath(activeHtmlId)
+ const newParentPath = getPath(overHtmlId)
+ const previousParentPath = childPath.slice(0, -1)
+ if (!childPath || !newParentPath || !previousParentPath) return
+
+ const childId = childPath.at(-1)
+ const newParentId = newParentPath.at(-1)
+ const previousParentId = previousParentPath.at(-1)
+ if (!childId || !previousParentId || !newParentId) return
+
+ const childNode = this.flatTagGraph[previousParentId].find(
+ (node) => node.childId === childId
+ )
+ if (!childNode) return
+
+ const isNoop = lodash.isEqual(previousParentPath, newParentPath)
+ if (isNoop) return
+
+ // Prevents these two structures:
+ // Parantheses indicate the subgraph that was dagged
+ // Energy -> (Energy)
+ // Nuclear Energy -> (Energy -> Nuclear Energy)
+ const allChildrenOfChild = this.getAllChildrenOfNode(childId)
+ const isCyclical = [childNode, ...allChildrenOfChild].find((child) =>
+ newParentPath.includes(child.childId)
+ )
+ if (isCyclical) {
+ alert("This operation would create a cycle")
+ return
+ }
+ const isSibling = this.flatTagGraph[newParentId]?.find(
+ (node) => node.childId === childId
+ )
+ if (isSibling) {
+ alert("This parent already has this child")
+ return
+ }
+
+ // Add child to new parent
+ childNode.parentId = newParentId
+ this.flatTagGraph[newParentId] = insertChildAndSort(
+ this.flatTagGraph[newParentId],
+ childNode
+ )
+
+ // Remove child from previous parent
+ this.flatTagGraph[previousParentId] = this.flatTagGraph[
+ previousParentId
+ ].filter((node) => node.childId !== childId)
+ }
+
+ @action.bound async saveTagGraph() {
+ if (!this.tagGraph) return
+ await this.context.admin.requestJSON(
+ "/api/tagGraph",
+ { tagGraph: toJS(this.flatTagGraph) },
+ "POST"
+ )
+ }
+
+ render() {
+ return (
+
+
+
+
Tag Graph
+
+
+
+
+
+
+ Drag and drop tags according to their hierarchy. Top
+ level tags should be areas. Tags are ordered by weight
+ (higher weights first) and then by name. To add a new
+ tag, visit the tags page.
+