123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254 |
- <template>
- <svg
- width="100%" height="100%"
- ref="svg" :id="id"
- :view-box.camel="viewBox"
- >
- <g :id="id + '-map'" v-if="ready">
- <g>
- <path
- v-for="(item, index) in links"
- :key="`link_${index}`"
- :d="lineGenerator([item.source, item.target])"
- :class="['link', item.linkType || item.target.data.linkType]"
- />
- </g>
- <circle
- v-for="(node, index) in nodes"
- :key="'circle' + index"
- :cx="node.x"
- :cy="node.y"
- class="svg-dot-btn"
- :class="['svg-dot-btn-' + node.data.variant, { 'origin': node.data.isOrigin }]"
- tabindex="0"
- @click="onNodeClick(node.data)"
- @keyup.enter="onNodeClick(node.data)"
- @mouseenter="activeNode = node"
- />
- <g>
- <!-- rect for forcing full render -->
- <rect
- x="-50%" y="-50%" fill="transparent"
- width="100%" height="100%" style="pointer-events: none;"
- />
- <foreignObject
- v-if="activeNode"
- :x="activeNode.x"
- :y="activeNode.y"
- >
- <dot-button
- :variant="activeNode.data.variant"
- class="active"
- @click="onNodeClick(activeNode.data)"
- @mouseleave="activeNode = null"
- >
- <template v-if="activeNode.data.variant !== 'depart'">
- {{ activeNode.data.families[0].name }}<br>
- </template>
- {{ toCommaList(activeNode.data.authors) }},<br>
- <span v-if="activeNode.data.field_titre_regular" v-html="activeNode.data.field_titre_regular + ','" />
- <span v-html="activeNode.data.field_titre_italique || '<em>pas de titre ital</em>'" />
- </dot-button>
- </foreignObject>
- </g>
- </g>
- </svg>
- </template>
- <script>
- import { line } from 'd3-shape'
- import { selectAll } from 'd3-selection'
- import { select, zoom } from 'd3'
- import { drag } from 'd3-drag'
- import {
- forceSimulation, forceLink, forceManyBody,
- forceX, forceY
- } from 'd3-force'
- export default {
- /*
- This component uses vue as the svg renderer and the rest is handle with d3 functions
- (events, data computing, etc.)
- */
- name: 'NodeMap',
- props: {
- nodes: { type: Array, required: true },
- links: { type: Array, required: true },
- id: { type: String, default: 'node-map' }
- },
- data () {
- return {
- ready: false,
- width: 100,
- height: 100,
- simulation: forceSimulation(),
- lineGenerator: line().x(node => node.x).y(node => node.y),
- activeNode: null
- }
- },
- computed: {
- viewBox () {
- const { width, height } = this
- return `-${width / 2} -${height / 2} ${width} ${height}`
- }
- },
- methods: {
- toCommaList (arr) {
- // FIXME TEMP some texts doesn't have authors
- try {
- return arr.map(({ name }) => name).join(', ')
- } catch {
- return 'pas d\'auteur⋅rices'
- }
- },
- updateSize () {
- const { width, height } = this.$el.getBoundingClientRect()
- Object.assign(this.$data, { width, height })
- },
- init () {
- this.updateSize()
- this.nodes.forEach(node => {
- this.$set(node, 'x', this.width * 0.5)
- this.$set(node, 'y', this.height * 0.5)
- })
- this.setupForces()
- // Wait for DOM update so d3 can select
- this.$nextTick(this.initListeners)
- this.ready = true
- },
- initListeners () {
- // TODO replace with vue events ?
- const svg = select('#' + this.id)
- const map = select('#' + this.id + '-map')
- svg.call(zoom()
- .extent([[0, 0], [this.width, this.height]])
- .scaleExtent([0.3, 1])
- .on('zoom', ({ transform }) => {
- map.attr('transform', transform)
- }))
- selectAll(`#${this.id} circle`)
- .data(this.nodes)
- .call(drag()
- .on('start', this.onNodeDragStart)
- .on('drag', this.onNodeDrag)
- .on('end', this.onNodeDragEnd)
- )
- },
- setupForces () {
- this.simulation
- .nodes(this.nodes)
- .force('charge', forceManyBody().strength((d) => {
- if (d.data.linkType === 'parents') return -2000
- if (d.data.linkType === 'siblings') return -500
- return -1000
- }))
- .force('link', forceLink(this.links).id(d => d.id).distance((d) => {
- if (d.linkType === 'parents') return 200
- if (d.linkType === 'siblings') return 100
- return 0
- }).strength(0.5))
- .force('x', forceX())
- .force('y', forceY())
- },
- // D3 DRAG EVENT HANDLERS
- onNodeDragStart (e, node) {
- if (!e.active) {
- this.simulation.alphaTarget(0.8).restart()
- }
- node.fx = node.x
- node.fy = node.y
- },
- onNodeDrag (e, node) {
- node.fx = e.x
- node.fy = e.y
- },
- onNodeDragEnd (e, node) {
- if (!e.active) {
- this.simulation.alphaTarget(0)
- }
- node.fx = null
- node.fy = null
- },
- // BASIC HANDLERS
- onNodeClick (nodeData) {
- const { id, parents } = nodeData
- if (parents) {
- this.$emit('open', parents[0].id, id)
- } else {
- this.$emit('open', id)
- }
- }
- },
- mounted () {
- this.init()
- }
- }
- </script>
- <style lang="scss" scoped>
- .link {
- stroke: grey;
- vector-effect: non-scaling-stroke;
- &.siblings {
- stroke-dasharray: 4;
- }
- &.parents {
- stroke: red;
- opacity: .3;
- }
- }
- .svg-dot-btn {
- r: 9.5px;
- @each $color, $value in $theme-colors {
- &-#{$color} {
- fill: $value;
- @if $color == 'depart' {
- stroke: $black;
- stroke-width: 3px;
- vector-effect: non-scaling-stroke;
- }
- }
- }
- &.origin {
- r: 15px;
- }
- }
- foreignObject {
- height: 1px;
- width: 1px;
- overflow: visible;
- }
- .dot-btn {
- vertical-align: top;
- transform: translate(-50%, -50%);
- }
- </style>
|