Node Images in Neo4j Brower

I found an old repo: GitHub - graphadvantage/neo4j-browser-images which allowed for the rendering of images on nodes in Neo4j Browser. However, this project seems to have been abandoned by the author 6 or 7 years ago.

So I got it working again. Simply amend

neo4j-browser/src/neo4j-arc/graph-visualization/GraphVisualizer/Graph/visualization/renderers/init.ts

as Follows:

/*
 * Copyright (c) "Neo4j"
 * Neo4j Sweden AB [http://neo4j.com]
 *
 * This file is part of Neo4j.
 *
 * Neo4j is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
import { BaseType } from 'd3-selection'

import { NodeCaptionLine, NodeModel } from '../../../../models/Node'
import { RelationshipModel } from '../../../../models/Relationship'
import Renderer from '../Renderer'

const noop = () => undefined

const nodeRingStrokeSize = 8

const nodeOutline = new Renderer<NodeModel>({
  name: 'nodeOutline',
  onGraphChange(selection, viz) {
    return selection
      .selectAll('circle.b-outline')
      .data(node => [node])
      .join('circle')
      .classed('b-outline', true)
      .attr('cx', 0)
      .attr('cy', 0)
      .attr('r', (node: NodeModel) => {
        return node.radius
      })
      .attr('fill', (node: NodeModel) => {
        return viz.style.forNode(node).get('color')
      })
      .attr('stroke', (node: NodeModel) => {
        return viz.style.forNode(node).get('border-color')
      })
      .attr('stroke-width', (node: NodeModel) => {
        return viz.style.forNode(node).get('border-width')
      })
  },
  onTick: noop
})

const nodeCaption = new Renderer<NodeModel>({
  name: 'nodeCaption',
  onGraphChange(selection, viz) {
    return (
      selection
        .selectAll('text.caption')
        .data((node: NodeModel) => node.caption)
        .join('text')
        // Classed element ensures duplicated data will be removed before adding
        .classed('caption', true)
        .attr('text-anchor', 'middle')
        .attr('pointer-events', 'none')
        .attr('x', 0)
        .attr('y', (line: NodeCaptionLine) => line.baseline)
        .attr('font-size', (line: NodeCaptionLine) =>
          viz.style.forNode(line.node).get('font-size')
        )
        .attr('fill', (line: NodeCaptionLine) =>
          viz.style.forNode(line.node).get('text-color-internal')
        )
        .text((line: NodeCaptionLine) => line.text)
    )
  },

  onTick: noop
})

const nodeRing = new Renderer<NodeModel>({
  name: 'nodeRing',
  onGraphChange(selection) {
    const circles = selection
      .selectAll('circle.ring')
      .data((node: NodeModel) => [node])

    circles
      .enter()
      .insert('circle', '.b-outline')
      .classed('ring', true)
      .attr('cx', 0)
      .attr('cy', 0)
      .attr('stroke-width', `${nodeRingStrokeSize}px`)
      .attr('r', (node: NodeModel) => node.radius + 4)

    return circles.exit().remove()
  },

  onTick: noop
})

const nodeImage = new Renderer<NodeModel>({
  name: 'nodeImage',
  onGraphChange (selection) {
    const pattern = selection.selectAll('pattern').data(function (node) {
      if (node.propertyMap.ImageURL) {
        return [node]
      } else {
        return []
      }
    })

    pattern
      .enter()
      .append('pattern')
        .attr('id', (id) => { return 'img-fill-' + id.id} ) 
        .attr('patternUnits', 'userSpaceOnUse')
        .attr('x', -39)
        .attr('y', -39)
        .attr('height', 156)
        .attr('width', 156)
      .append('image')
        .attr('xlink:href', link => link.propertyMap.ImageURL)
        .attr('x', 0)
        .attr('y', 0)
        .attr('type', 'image/png')
        .attr('height', 78)
        .attr('width', 78)

    return pattern.exit().remove()
  },

  onTick: noop
})


const nodeImageFill = new Renderer<NodeModel>({
  name: 'nodeImageFill',
  onGraphChange (selection) {
    const filledCircle = selection
      .selectAll('circle.filled')
      .data(node => [node])

    /*! console.log(filledCircle.data()) */

    filledCircle
      .enter()
      .append('circle')
        .classed('filled', true)
        .attr('cx',0)
        .attr('cy',0)
        .attr('r', (node) => {return node.radius-1})
        .attr('fill', (id) => {return 'url(#img-fill-' + id.id + ')'})

    return filledCircle.exit().remove()
  },

  onTick: noop
})


const arrowPath = new Renderer<RelationshipModel>({
  name: 'arrowPath',

  onGraphChange(selection, viz) {
    return selection
      .selectAll('path.b-outline')
      .data((rel: any) => [rel])
      .join('path')
      .classed('b-outline', true)
      .attr('fill', (rel: any) => viz.style.forRelationship(rel).get('color'))
      .attr('stroke', 'none')
  },

  onTick(selection) {
    return selection
      .selectAll<BaseType, RelationshipModel>('path')
      .attr('d', d => d.arrow!.outline(d.shortCaptionLength ?? 0))
  }
})

const relationshipType = new Renderer<RelationshipModel>({
  name: 'relationshipType',
  onGraphChange(selection, viz) {
    return selection
      .selectAll('text')
      .data(rel => [rel])
      .join('text')
      .attr('text-anchor', 'middle')
      .attr('pointer-events', 'none')
      .attr('font-size', rel => viz.style.forRelationship(rel).get('font-size'))
      .attr('fill', rel =>
        viz.style.forRelationship(rel).get(`text-color-${rel.captionLayout}`)
      )
  },

  onTick(selection, viz) {
    return selection
      .selectAll<BaseType, RelationshipModel>('text')
      .attr('x', rel => rel?.arrow?.midShaftPoint?.x ?? 0)
      .attr(
        'y',
        rel =>
          (rel?.arrow?.midShaftPoint?.y ?? 0) +
          parseFloat(viz.style.forRelationship(rel).get('font-size')) / 2 -
          1
      )
      .attr('transform', rel => {
        if (rel.naturalAngle < 90 || rel.naturalAngle > 270) {
          return `rotate(180 ${rel?.arrow?.midShaftPoint?.x ?? 0} ${
            rel?.arrow?.midShaftPoint?.y ?? 0
          })`
        } else {
          return null
        }
      })
      .text(rel => rel.shortCaption ?? '')
  }
})

const relationshipOverlay = new Renderer<RelationshipModel>({
  name: 'relationshipOverlay',
  onGraphChange(selection) {
    return selection
      .selectAll('path.overlay')
      .data(rel => [rel])
      .join('path')
      .classed('overlay', true)
  },

  onTick(selection) {
    const band = 16

    return selection
      .selectAll<BaseType, RelationshipModel>('path.overlay')
      .attr('d', d => d.arrow!.overlay(band))
  }
})

const node = [nodeOutline, nodeImage, nodeImageFill, nodeCaption, nodeRing]

const relationship = [arrowPath, relationshipType, relationshipOverlay]

export { node, relationship }

Now if you set an imageURL property on a node, it will render an image.

I really think this should become part of the project.