NodeMap.vue 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. <template>
  2. <svg
  3. width="100%" height="100%"
  4. ref="svg" :id="id"
  5. :view-box.camel="viewBox"
  6. >
  7. <g :id="id + '-map'" v-if="ready">
  8. <g>
  9. <path
  10. v-for="(item, index) in links"
  11. :key="`link_${index}`"
  12. :d="lineGenerator([item.source, item.target])"
  13. :class="['link', item.linkType || item.target.data.linkType]"
  14. />
  15. </g>
  16. <circle
  17. v-for="(node, index) in nodes"
  18. :key="'circle' + index"
  19. :cx="node.x"
  20. :cy="node.y"
  21. class="svg-dot-btn"
  22. :class="['svg-dot-btn-' + node.data.variant, { 'origin': node.data.isOrigin }]"
  23. tabindex="0"
  24. @click="onNodeClick(node.data)"
  25. @keyup.enter="onNodeClick(node.data)"
  26. @mouseenter="activeNode = node"
  27. />
  28. <g>
  29. <!-- rect for forcing full render -->
  30. <rect
  31. x="-50%" y="-50%" fill="transparent"
  32. width="100%" height="100%" style="pointer-events: none;"
  33. />
  34. <foreignObject
  35. v-if="activeNode"
  36. :x="activeNode.x"
  37. :y="activeNode.y"
  38. >
  39. <dot-button
  40. :variant="activeNode.data.variant"
  41. class="active"
  42. @click="onNodeClick(activeNode.data)"
  43. @mouseleave="activeNode = null"
  44. >
  45. <template v-if="activeNode.data.variant !== 'depart'">
  46. {{ activeNode.data.families[0].name }}<br>
  47. </template>
  48. {{ toCommaList(activeNode.data.authors) }},<br>
  49. <span v-if="activeNode.data.field_titre_regular" v-html="activeNode.data.field_titre_regular + ','" />
  50. <span v-html="activeNode.data.field_titre_italique || '<em>pas de titre ital</em>'" />
  51. </dot-button>
  52. </foreignObject>
  53. </g>
  54. </g>
  55. </svg>
  56. </template>
  57. <script>
  58. import { line } from 'd3-shape'
  59. import { selectAll } from 'd3-selection'
  60. import { select, zoom } from 'd3'
  61. import { drag } from 'd3-drag'
  62. import {
  63. forceSimulation, forceLink, forceManyBody,
  64. forceX, forceY
  65. } from 'd3-force'
  66. export default {
  67. /*
  68. This component uses vue as the svg renderer and the rest is handle with d3 functions
  69. (events, data computing, etc.)
  70. */
  71. name: 'NodeMap',
  72. props: {
  73. nodes: { type: Array, required: true },
  74. links: { type: Array, required: true },
  75. id: { type: String, default: 'node-map' }
  76. },
  77. data () {
  78. return {
  79. ready: false,
  80. width: 100,
  81. height: 100,
  82. simulation: forceSimulation(),
  83. lineGenerator: line().x(node => node.x).y(node => node.y),
  84. activeNode: null
  85. }
  86. },
  87. computed: {
  88. viewBox () {
  89. const { width, height } = this
  90. return `-${width / 2} -${height / 2} ${width} ${height}`
  91. }
  92. },
  93. methods: {
  94. toCommaList (arr) {
  95. // FIXME TEMP some texts doesn't have authors
  96. try {
  97. return arr.map(({ name }) => name).join(', ')
  98. } catch {
  99. return 'pas d\'auteur⋅rices'
  100. }
  101. },
  102. updateSize () {
  103. const { width, height } = this.$el.getBoundingClientRect()
  104. Object.assign(this.$data, { width, height })
  105. },
  106. init () {
  107. this.updateSize()
  108. this.nodes.forEach(node => {
  109. this.$set(node, 'x', this.width * 0.5)
  110. this.$set(node, 'y', this.height * 0.5)
  111. })
  112. this.setupForces()
  113. // Wait for DOM update so d3 can select
  114. this.$nextTick(this.initListeners)
  115. this.ready = true
  116. },
  117. initListeners () {
  118. // TODO replace with vue events ?
  119. const svg = select('#' + this.id)
  120. const map = select('#' + this.id + '-map')
  121. svg.call(zoom()
  122. .extent([[0, 0], [this.width, this.height]])
  123. .scaleExtent([0.3, 1])
  124. .on('zoom', ({ transform }) => {
  125. map.attr('transform', transform)
  126. }))
  127. selectAll(`#${this.id} circle`)
  128. .data(this.nodes)
  129. .call(drag()
  130. .on('start', this.onNodeDragStart)
  131. .on('drag', this.onNodeDrag)
  132. .on('end', this.onNodeDragEnd)
  133. )
  134. },
  135. setupForces () {
  136. this.simulation
  137. .nodes(this.nodes)
  138. .force('charge', forceManyBody().strength((d) => {
  139. if (d.data.linkType === 'parents') return -2000
  140. if (d.data.linkType === 'siblings') return -500
  141. return -1000
  142. }))
  143. .force('link', forceLink(this.links).id(d => d.id).distance((d) => {
  144. if (d.linkType === 'parents') return 200
  145. if (d.linkType === 'siblings') return 100
  146. return 0
  147. }).strength(0.5))
  148. .force('x', forceX())
  149. .force('y', forceY())
  150. },
  151. // D3 DRAG EVENT HANDLERS
  152. onNodeDragStart (e, node) {
  153. if (!e.active) {
  154. this.simulation.alphaTarget(0.8).restart()
  155. }
  156. node.fx = node.x
  157. node.fy = node.y
  158. },
  159. onNodeDrag (e, node) {
  160. node.fx = e.x
  161. node.fy = e.y
  162. },
  163. onNodeDragEnd (e, node) {
  164. if (!e.active) {
  165. this.simulation.alphaTarget(0)
  166. }
  167. node.fx = null
  168. node.fy = null
  169. },
  170. // BASIC HANDLERS
  171. onNodeClick (nodeData) {
  172. const { id, parents } = nodeData
  173. if (parents) {
  174. this.$emit('open', parents[0].id, id)
  175. } else {
  176. this.$emit('open', id)
  177. }
  178. }
  179. },
  180. mounted () {
  181. this.init()
  182. }
  183. }
  184. </script>
  185. <style lang="scss" scoped>
  186. .link {
  187. stroke: grey;
  188. vector-effect: non-scaling-stroke;
  189. &.siblings {
  190. stroke-dasharray: 4;
  191. }
  192. &.parents {
  193. stroke: red;
  194. opacity: .3;
  195. }
  196. }
  197. .svg-dot-btn {
  198. r: 9.5px;
  199. @each $color, $value in $theme-colors {
  200. &-#{$color} {
  201. fill: $value;
  202. @if $color == 'depart' {
  203. stroke: $black;
  204. stroke-width: 3px;
  205. vector-effect: non-scaling-stroke;
  206. }
  207. }
  208. }
  209. &.origin {
  210. r: 15px;
  211. }
  212. }
  213. foreignObject {
  214. height: 1px;
  215. width: 1px;
  216. overflow: visible;
  217. }
  218. .dot-btn {
  219. vertical-align: top;
  220. transform: translate(-50%, -50%);
  221. }
  222. </style>