function-chart.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. import * as d3 from 'd3'
  2. import { Point as PointPosition } from './gradient-descent'
  3. import { SingleVarFunction } from './functions'
  4. type NumberScaleLinear = d3.ScaleLinear<number, number>
  5. type ContainerSelection = d3.Selection<d3.ContainerElement, unknown, HTMLElement, any>
  6. type SVGSelection = d3.Selection<SVGGElement, unknown, HTMLElement, any>
  7. type SVGPathSelection = d3.Selection<SVGPathElement, unknown, HTMLElement, any>
  8. const FLOAT_COMPARE_TOLERANCE = 0.000001
  9. interface Point {
  10. x: number
  11. y: number
  12. circle: d3.Selection<SVGCircleElement, unknown, HTMLElement, any>
  13. lines: d3.Selection<SVGLineElement, unknown, HTMLElement, any>[]
  14. }
  15. interface Context {
  16. groundFunc: SingleVarFunction
  17. chart: SVGSelection
  18. functionLine: SVGPathSelection
  19. points: Point[]
  20. }
  21. export class FunctionChart {
  22. private xScale: NumberScaleLinear
  23. private yScale: NumberScaleLinear
  24. chart: SVGSelection
  25. private points: Point[] = []
  26. private functionLine: SVGPathSelection
  27. private domain: number[]
  28. private range: number[]
  29. private listeners: ((context: Context) => void)[] = []
  30. private container: ContainerSelection
  31. constructor(private groundFunc: SingleVarFunction, container: ContainerSelection) {
  32. this.container = container
  33. const node = container.node() as HTMLElement
  34. const totalWidth = node.offsetWidth
  35. const totalHeight = node.offsetHeight
  36. const margin = { top: 60, right: 60, bottom: 60, left: 60 }
  37. const width = totalWidth - margin.left - margin.right
  38. const height = totalHeight - margin.top - margin.bottom
  39. this.domain = groundFunc.domain
  40. this.range = groundFunc.range
  41. ;[this.xScale, this.yScale] = this.makeScale(width, height)
  42. // Remove previous chart
  43. container.select('svg').remove()
  44. // Render chart
  45. this.chart = container
  46. .append('svg')
  47. .attr('width', totalWidth)
  48. .attr('height', totalHeight)
  49. .append('g')
  50. .attr('transform', `translate(${margin.left}, ${margin.top})`)
  51. this.functionLine = this.renderChart(width, height)
  52. }
  53. performActionOnChart(callback: (context: Context) => void) {
  54. const context = {
  55. groundFunc: this.groundFunc,
  56. chart: this.chart,
  57. functionLine: this.functionLine,
  58. points: this.points
  59. }
  60. callback(context)
  61. this.listeners.push(callback)
  62. }
  63. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  64. private renderChart(width: number, height: number) {
  65. const chart = this.chart
  66. // Append scale
  67. chart
  68. .append('g')
  69. .call(d3.axisBottom(this.xScale).ticks(10))
  70. .attr('transform', `translate(0, ${height})`)
  71. chart.append('g').call(d3.axisLeft(this.yScale).ticks(10))
  72. // Draw line
  73. const points: [number, number][] = this.xScale
  74. .ticks(100)
  75. .map(x => [this.xScale(x), this.yScale(this.groundFunc.f(x))])
  76. const path = d3.line().curve(d3.curveBasis)(points)
  77. return chart
  78. .append('path')
  79. .classed('function-line', true)
  80. .attr('d', _ => path)
  81. .style('fill', 'none')
  82. .style('stroke', '#348')
  83. .style('stroke-width', '1.5px')
  84. }
  85. private makeScale(width: number, height: number) {
  86. const xScale = d3
  87. .scaleLinear()
  88. .domain(this.domain)
  89. .range([0, width])
  90. .nice()
  91. const yScale = d3
  92. .scaleLinear()
  93. .domain(this.range)
  94. .range([height, 0])
  95. .nice()
  96. return [xScale, yScale]
  97. }
  98. getPoints() {
  99. return this.points
  100. }
  101. setPoint(index: number, p: PointPosition) {
  102. const posX = this.xScale(p.x)
  103. const posY = this.yScale(p.y)
  104. // Update related circles
  105. const target = index === -1 ? this.points[this.points.length - 1] : this.points[index]
  106. target.circle.attr('transform', `translate(${posX}, ${posY})`)
  107. // Update related lines
  108. for (const line of target.lines) {
  109. const x1 = Number.parseFloat(line.attr('x1'))
  110. const y1 = Number.parseFloat(line.attr('y1'))
  111. if (
  112. Math.abs(x1 - posX) < FLOAT_COMPARE_TOLERANCE &&
  113. Math.abs(y1 - posY) < FLOAT_COMPARE_TOLERANCE
  114. ) {
  115. line.attr('x1', posX)
  116. line.attr('y1', posY)
  117. } else {
  118. line.attr('x2', posX)
  119. line.attr('y2', posY)
  120. }
  121. }
  122. }
  123. appendPoint(x: number) {
  124. const circleId = this.points.length
  125. const posX = this.xScale(x)
  126. const posY = this.yScale(this.groundFunc.f(x))
  127. const circle = this.chart
  128. .append('circle')
  129. .attr('id', `circle-${circleId}`)
  130. .attr('r', 3)
  131. .attr('transform', `translate(${posX}, ${posY})`)
  132. this.points.push({ x, y: this.groundFunc.f(x), circle, lines: [] })
  133. }
  134. appendPointByRealPosition(posX: number) {
  135. const circleId = this.points.length
  136. const x = this.xScale.invert(posX)
  137. const posY = this.yScale(this.groundFunc.f(x))
  138. const circle = this.chart
  139. .append('circle')
  140. .attr('id', `circle-${circleId}`)
  141. .attr('r', 3)
  142. .attr('transform', `translate(${posX}, ${posY})`)
  143. this.points.push({ x, y: this.groundFunc.f(x), circle, lines: [] })
  144. }
  145. drawLineAt(point1Index: number, point2Index: number) {
  146. this.drawLineBetweenTwoPoints(this.points[point1Index], this.points[point2Index])
  147. }
  148. drawTangentAtPoint(pointIndex: number) {
  149. this.drawTangent(this.points[pointIndex])
  150. }
  151. showDiffAt(point1Index: number, point2Index: number) {
  152. this.showDiffBetweenPoints(this.points[point1Index], this.points[point2Index])
  153. }
  154. private showDiffBetweenPoints(point1: Point, point2: Point) {
  155. // Get cross point location
  156. const x = Math.min(point1.x, point2.x)
  157. const y = Math.max(point1.y, point2.y)
  158. const posX = this.xScale(x)
  159. const posY = this.yScale(y)
  160. const diff = this.chart.append('g')
  161. diff
  162. .append('circle')
  163. .attr('id', `diff-point-${x}-${y}`)
  164. .attr('r', 3)
  165. .attr('transform', `translate(${posX}, ${posY})`)
  166. diff
  167. .append('line')
  168. .classed('diff-line', true)
  169. .attr('x1', posX)
  170. .attr('x2', x === point1.x ? this.xScale(point2.x) : this.xScale(point1.x))
  171. .attr('y1', posY)
  172. .attr('y2', posY)
  173. diff
  174. .append('line')
  175. .classed('diff-line', true)
  176. .attr('y1', posY)
  177. .attr('y2', y === point1.y ? this.yScale(point2.y) : this.yScale(point1.y))
  178. .attr('x1', posX)
  179. .attr('x2', posX)
  180. }
  181. private drawLineBetweenTwoPoints(point1: Point, point2: Point) {
  182. if (point1.x === point2.x && point1.y === point2.y) {
  183. // Draw tangent
  184. this.drawTangent(point1)
  185. } else {
  186. const slope = (point1.y - point2.y) / (point1.x - point2.x)
  187. const lineFunc = (x: number) => (x - point1.x) * slope + point1.y
  188. const line = this.drawStraightLine(lineFunc)
  189. point1.lines.push(line)
  190. point2.lines.push(line)
  191. }
  192. }
  193. private drawTangent(point: Point) {
  194. const slope = this.groundFunc.df(point.x)
  195. const lineFunc = (x: number) => (x - point.x) * slope + point.y
  196. point.lines.push(this.drawStraightLine(lineFunc))
  197. }
  198. private drawStraightLine(lineFunc: (x: number) => number) {
  199. const startPoint = [this.xScale(this.domain[0]), this.yScale(lineFunc(this.domain[0]))]
  200. const endPoint = [this.xScale(this.domain[1]), this.yScale(lineFunc(this.domain[1]))]
  201. return this.chart
  202. .append('line')
  203. .style('stroke', 'black')
  204. .attr('x1', startPoint[0])
  205. .attr('y1', startPoint[1])
  206. .attr('x2', endPoint[0])
  207. .attr('y2', endPoint[1])
  208. }
  209. }