step-chart.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. import * as d3 from 'd3'
  2. type NodeType = 'normal' | 'start' | 'end'
  3. type Direction = 'top' | 'right' | 'bottom' | 'left'
  4. export class StepNode {
  5. description: string
  6. label: string
  7. value: string = ''
  8. inputLinks: Link[] = []
  9. outputLinks: Link[] = []
  10. id: number
  11. type: NodeType
  12. constructor(description: string, label: string, id: number, type: NodeType = 'normal') {
  13. this.description = description
  14. this.label = label
  15. this.id = id
  16. this.type = type
  17. }
  18. static buildStepNodes(stepsInfo: { description: string; label: string }[]): StepNode[] {
  19. return stepsInfo.map((info, i) => new StepNode(info.description, info.label, i))
  20. }
  21. }
  22. interface Link {
  23. source: StepNode
  24. dest: StepNode
  25. id: string
  26. }
  27. type ContainerSelection = d3.Selection<d3.ContainerElement, unknown, HTMLElement, any>
  28. type SVGGSelection = d3.Selection<SVGGElement, unknown, HTMLElement, any>
  29. type SVGSVGSlection = d3.Selection<SVGSVGElement, unknown, HTMLElement, any>
  30. type SVGPathSelection = d3.Selection<SVGPathElement, unknown, HTMLElement, any>
  31. const STEP_MARGIN = 30
  32. const STEP_CARD_HEIGHT = 100
  33. const nodeScale = (index: number) => index * (STEP_CARD_HEIGHT + STEP_MARGIN)
  34. export class StepChart {
  35. private steps: StepNode[]
  36. private container: ContainerSelection
  37. private chart: SVGGSelection
  38. private node2coord: { [id: string]: number } = {}
  39. constructor(container: ContainerSelection, steps: StepNode[]) {
  40. this.container = container
  41. this.steps = steps
  42. // Remove all svg and div elements
  43. container.select('svg g.core').remove()
  44. container.selectAll('div.step-card').remove()
  45. // Get width and height of svg
  46. const node = container.node() as HTMLDivElement
  47. const totalWidth = node.offsetWidth
  48. const totalHeight = node.offsetHeight
  49. // const margin = { top: 3, right: 3, bottom: 3, left: 3 }
  50. // const width = totalWidth - margin.left - margin.right
  51. // const height = totalHeight - margin.top - margin.bottom
  52. this.chart = container
  53. .select('svg')
  54. .attr('width', totalWidth)
  55. .attr('height', totalHeight)
  56. .append('g')
  57. .classed('core', true)
  58. // .attr('transform', `translate(${margin.left},${margin.top})`)
  59. }
  60. linkNode(sourceId: number, destId: number) {
  61. const sourceNode = this.steps[sourceId]
  62. const destNode = this.steps[destId]
  63. const link: Link = {
  64. source: sourceNode,
  65. dest: destNode,
  66. id: `link-${sourceId}-${destId}`
  67. }
  68. sourceNode.outputLinks.push(link)
  69. destNode.inputLinks.push(link)
  70. return this
  71. }
  72. render() {
  73. for (const step of this.steps) {
  74. this.drawStep(step)
  75. }
  76. for (const step of this.steps) {
  77. for (const link of step.outputLinks) {
  78. this.drawLink(link)
  79. }
  80. }
  81. if (window) {
  82. window.addEventListener('resize', () => {
  83. // Redraw links
  84. this.container.selectAll('.link').remove()
  85. for (const step of this.steps) {
  86. for (const link of step.outputLinks) {
  87. this.drawLink(link)
  88. }
  89. }
  90. })
  91. }
  92. }
  93. update(stepId: number, value: string) {
  94. // Remove all active class
  95. this.container
  96. .selectAll('.v-card--raised')
  97. .classed('v-card--raised', false)
  98. .classed('v-card--outlined', true)
  99. // Add active class to current step card
  100. const target = this.container
  101. .select('#step-card-' + stepId)
  102. .classed('v-card--raised', true)
  103. .classed('v-card--outlined', false)
  104. // Update step label
  105. target.select('.step-card__label').text(this.steps[stepId].label + ': ' + value)
  106. }
  107. private drawStep(step: StepNode) {
  108. const div = this.container.insert('div', ':first-child').attr('id', `step-card-${step.id}`)
  109. const y = nodeScale(step.id)
  110. this.node2coord[step.id] = y
  111. const classList = 'step-card v-card v-sheet theme--light v-card--outlined mx-auto'
  112. if (step.type === 'start') {
  113. div
  114. .classed(
  115. 'step-card v-card v-sheet theme--light step-card--start v-card--raised mx-auto',
  116. true
  117. )
  118. .style('top', '0px')
  119. div
  120. .append('div')
  121. .classed('v-card__title step-card__label', true)
  122. .text(step.label)
  123. .style('justify-content', 'center')
  124. } else if (step.type === 'end') {
  125. div.classed(classList + ' step-card--end', true).style('bottom', '0px')
  126. div
  127. .append('div')
  128. .classed('v-card__title step-card__label', true)
  129. .text(step.label)
  130. .style('justify-content', 'center')
  131. } else {
  132. div.classed(classList, true).style('top', y + 'px')
  133. // show description
  134. div
  135. .append('div')
  136. .classed('v-card__title step-card__label', true)
  137. .text(step.label + ' ' + step.value)
  138. div
  139. .append('div')
  140. .classed('v-card__text', true)
  141. .text(step.description)
  142. }
  143. }
  144. private drawLink(link: Link) {
  145. const sourceNode = link.source
  146. const destNode = link.dest
  147. const sourceDirection: Direction = sourceNode.id < destNode.id ? 'bottom' : 'left'
  148. const destDeirection: Direction = sourceNode.id < destNode.id ? 'top' : 'left'
  149. const sourcePos = this.getLinkEndPointPosition(sourceNode, sourceDirection)
  150. const destPos = this.getLinkEndPointPosition(destNode, destDeirection)
  151. if (sourceNode.id < destNode.id) {
  152. const data = {
  153. source: [sourcePos.x, sourcePos.y] as [number, number],
  154. target: [destPos.x, destPos.y] as [number, number]
  155. }
  156. this.chart
  157. .insert('path', ':first-child')
  158. .classed('link', true)
  159. .attr('marker-end', 'url(#markerArrow)')
  160. .attr('id', `link-${sourceNode.id}-${destNode.id}`)
  161. .attr('d', _ => d3.linkVertical()(data))
  162. } else {
  163. // Add middle point to draw a curve line
  164. const data = [
  165. [sourcePos.x, sourcePos.y],
  166. [sourcePos.x - 60, (sourcePos.y + destPos.y) / 2],
  167. [destPos.x, destPos.y]
  168. ] as [number, number][]
  169. this.chart
  170. .insert('path', ':first-child')
  171. .classed('link', true)
  172. .attr('marker-end', 'url(#markerArrow)')
  173. .attr('id', `link-${sourceNode.id}-${destNode.id}`)
  174. .attr('d', _ => d3.line().curve(d3.curveStep)(data))
  175. }
  176. }
  177. private getLinkEndPointPosition(node: StepNode, direction: Direction) {
  178. let y = this.node2coord[node.id]
  179. let x = (this.container.node() as HTMLDivElement).offsetWidth / 2
  180. const divNode = this.container.select('#step-card-' + node.id).node() as HTMLDivElement
  181. if (direction === 'left') {
  182. x -= divNode.offsetWidth / 2
  183. y += divNode.offsetHeight / 2
  184. } else if (direction === 'bottom') {
  185. y += divNode.offsetHeight
  186. } else if (direction === 'right') {
  187. x += divNode.offsetWidth / 2
  188. y += divNode.offsetHeight / 2
  189. }
  190. return { x, y }
  191. }
  192. }