main.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. import CanvasOperator from './canvasOperator.js'
  2. import CanvasRenderer from './canvasRenderer.js'
  3. import SeatCalculator from './canvasCalculator.js'
  4. import seatmapConfig from './config.js'
  5. import { StaticUrl } from '@/config'
  6. export default class seatmap {
  7. constructor({ canvas, seatList, options }) {
  8. this.ctx = canvas.getContext('2d')
  9. this.seatList = seatList
  10. this.options = this._deepMerge(seatmapConfig, options)
  11. this.checkOptions()
  12. this.operator = new CanvasOperator(canvas)
  13. this.renderer = new CanvasRenderer(this.ctx, this.options)
  14. this.calc = new SeatCalculator(this.seatList, this.options)
  15. // 运行时状态
  16. this.cellSize = 0
  17. this.panX = 0
  18. this.panY = 0
  19. this.boundaries = {}
  20. this.imageScreen = null
  21. this.imageGou = null
  22. this._drawing = false
  23. this._rafId = null
  24. this.seatRow = 0
  25. this.seatCol = 0
  26. this.miniMapShow = false
  27. this.firstClick = true
  28. this.init()
  29. }
  30. checkOptions() {
  31. const { SEAT_FIELDS, canvasWidth, canvasHeight, footerHeight, SEAT_STYLE, areaList } = this.options
  32. if (!SEAT_FIELDS.ROW_ID || typeof SEAT_FIELDS.ROW_ID !== 'string')
  33. throw new Error(`options.SEAT_FIELDS.ROW_ID 配置不正确`)
  34. if (!SEAT_FIELDS.COLUMN_ID || typeof SEAT_FIELDS.COLUMN_ID !== 'string')
  35. throw new Error(`options.SEAT_FIELDS.COLUMN_ID 配置不正确`)
  36. if (!SEAT_FIELDS.STATUS_NAME || typeof SEAT_FIELDS.STATUS_NAME !== 'string')
  37. throw new Error(`options.SEAT_FIELDS.STATUS_NAME 配置不正确`)
  38. if (!SEAT_FIELDS.TYPE_NAME || typeof SEAT_FIELDS.TYPE_NAME !== 'string')
  39. throw new Error(`options.SEAT_FIELDS.TYPE_NAME 配置不正确`)
  40. if (!SEAT_FIELDS.AREA_ID || typeof SEAT_FIELDS.AREA_ID !== 'string')
  41. throw new Error(`options.SEAT_FIELDS.AREA_ID 配置不正确`)
  42. if (!canvasWidth || canvasWidth <= 0)
  43. throw new Error(`options.canvasWidth 必须大于0`)
  44. if (!canvasHeight || canvasHeight <= 0)
  45. throw new Error(`options.canvasHeight 必须大于0`)
  46. if (footerHeight < 0)
  47. throw new Error(`options.footerHeight 不能为负`)
  48. const validateSeatStyle = SEAT_STYLE => ['DISABLED', 'LOCKED', 'SOLD', 'AVAILABLE', 'SELECTED'].every(prop =>
  49. SEAT_STYLE[prop]?.fillStyle !== undefined && SEAT_STYLE[prop]?.strokeStyle !== undefined,
  50. )
  51. if (validateSeatStyle(SEAT_STYLE) === false)
  52. throw new Error(`options.SEAT_STYLE 配置不正确,每个状态都需要配置 fillStyle 和 strokeStyle`)
  53. areaList.forEach((item) => {
  54. if (item[SEAT_FIELDS.AREA_ID] === undefined)
  55. throw new Error(`options.areaList 中缺少 AREA_ID 字段`)
  56. if (item.strokeStyle === undefined)
  57. throw new Error(`options.areaList 中缺少 strokeStyle 字段(颜色)`)
  58. })
  59. }
  60. async init() {
  61. const { canvasWidth, canvasHeight, footerHeight } = this.options
  62. // 加载图片
  63. // this.imageScreen = await this.operator.loadImage(`${StaticUrl}/film-choose-icon.png`)
  64. // this.imageGou = await this.operator.loadImage(`${StaticUrl}/film-choose-icon.png`)
  65. this.imageScreen = await this.operator.loadImage(`${StaticUrl}/film-hall.png`)
  66. this.imageGou = await this.operator.loadImage(`${StaticUrl}/film-gou-old.png`)
  67. // 初始布局
  68. this.cellSize = this.calc.calcCellSize(canvasWidth)
  69. const { seatRow, seatCol } = this.calc.calcSeatRowCol()
  70. const { panX, panY } = this.calc.calcInitialPan(canvasWidth, canvasHeight, this.cellSize)
  71. this.boundaries = this.calc.getBoundaries(canvasWidth, canvasHeight, footerHeight, this.cellSize)
  72. this.seatRow = seatRow
  73. this.seatCol = seatCol
  74. this.panX = panX
  75. this.panY = panY
  76. this.requestDraw()
  77. }
  78. /* -------------------- 座位图绘制 -------------------- */
  79. requestDraw() {
  80. if (this._drawing)
  81. return
  82. this._drawing = true
  83. this._rafId = this.operator.raf(() => {
  84. const { panX, panY, cellSize, seatRow, seatCol, seatList, imageGou, imageScreen, miniMapShow } = this
  85. const { title, canvasWidth, hallHeight, areaColorMap, hallName } = this.options
  86. this.renderer.clear(0, 0, this.options.canvasWidth, this.options.canvasHeight)
  87. this.renderer.drawSeatMap({ panX, panY, cellSize, seatRow, seatCol, seatList, areaColorMap, imageGou })
  88. this.renderer.drawCenterLine({ panX, panY, cellSize, seatRow, seatCol, title })
  89. this.renderer.drawScreen({ panX, panY, cellSize, seatRow, seatCol, imageScreen, hallName, canvasWidth, hallHeight })
  90. this.renderer.drawRowLabels({ panX, panY, cellSize, seatRow, seatCol, seatArray: this.calc.seatArray })
  91. this.renderer.drawMiniMap({ panX, panY, cellSize, seatRow, seatCol, seatList, canvasWidth, miniMapShow, hallHeight })
  92. this._drawing = false
  93. })
  94. }
  95. /* -------------------- 触摸事件 -------------------- */
  96. onTouchStart(e) {
  97. this._startPanX = this.panX
  98. this._startPanY = this.panY
  99. this._lastTouch = e.touches[0]
  100. this._startTouchEvent = e
  101. this._isPinching = false
  102. if (e.touches.length === 2) {
  103. this._isPinching = true
  104. this._lastPinchDist = this.calc.calcDistance(e.touches[0], e.touches[1])
  105. }
  106. }
  107. onTouchMove(e) {
  108. if (e.touches.length === 1 && !this._isPinching) {
  109. this.miniMapShow = true // 拖拽时显示小图
  110. const dx = e.touches[0].x - this._lastTouch.x
  111. const dy = e.touches[0].y - this._lastTouch.y
  112. this.panX = this._startPanX + dx
  113. this.panY = this._startPanY + dy
  114. this.requestDraw()
  115. return
  116. }
  117. if (e.touches.length === 2 && this._isPinching) {
  118. this.miniMapShow = true
  119. if (!this._lastPinchDist)
  120. return
  121. const dist = this.calc.calcDistance(e.touches[0], e.touches[1])
  122. const centerX = (e.touches[0].x + e.touches[1].x) / 2
  123. const centerY = (e.touches[0].y + e.touches[1].y) / 2
  124. const scale = dist / this._lastPinchDist
  125. const cellSize = this.cellSize * scale
  126. this.applyZoom(cellSize, centerX, centerY)
  127. this._lastPinchDist = dist
  128. this.requestDraw()
  129. }
  130. }
  131. onTouchEnd() {
  132. this._afterDrawTimer()
  133. const { targetX, targetY } = this.calc.clampOffset(this.panX, this.panY, this.boundaries)
  134. if (this.panX === targetX && this.panY === targetY)
  135. return
  136. this._animateTo(targetX, targetY)
  137. }
  138. /* -------------------- 选座 -------------------- */
  139. async onSeatTap({ x, y }) {
  140. const { panX, panY } = this
  141. const { AVAILABLE, SELECTED } = this.options.SEAT_STATUS
  142. const { STATUS_NAME } = this.options.SEAT_FIELDS
  143. const { seatMaxWidth, maxSelectNum, isolateSeats } = this.options
  144. const { canvasX, canvasY } = this.calc.clientToCanvas({ x, y, panX, panY })
  145. const seat = this.seatList.find((item) => {
  146. if (!item.points)
  147. return false
  148. const { startX, startY, endX, endY } = item.points
  149. return (canvasX >= startX && canvasX <= endX && canvasY >= startY && canvasY <= endY)
  150. })
  151. if (!seat || ![AVAILABLE, SELECTED].includes(seat[STATUS_NAME])) {
  152. return { ok: true, msg: '不是有效座位' }
  153. }
  154. const indices = this.calc.getRelatedSeatIndices(seat)
  155. const adjustedNum = seat[STATUS_NAME] === SELECTED ? -indices.length : indices.length
  156. if (maxSelectNum && this.chooseSeatList().length + adjustedNum > maxSelectNum) {
  157. return { ok: false, msg: `最多可选${maxSelectNum}个座位` }
  158. };
  159. const targetStatus = seat[STATUS_NAME] === SELECTED ? AVAILABLE : SELECTED
  160. this.calc.updateSeatStatus(targetStatus, indices)
  161. if (this.firstClick) {
  162. this.applyZoom(seatMaxWidth, canvasX, canvasY)
  163. this.firstClick = false
  164. }
  165. this.requestDraw()
  166. if (seat[STATUS_NAME] === SELECTED && isolateSeats && this.calc.validateSeat(seat)) {
  167. return { ok: false, msg: '左右两边不要留空哦' }
  168. }
  169. return { ok: true, msg: '点击座位成功' }
  170. }
  171. applyZoom(cellSize, centerX, centerY) {
  172. const { panX, panY, cellSize: oldCellSize, seatCol } = this
  173. const { canvasWidth, seatMaxWidth, seatMinWidth } = this.options
  174. const zoom = this.calc.calcPinchZoom({ oldCellSize, scale: cellSize / oldCellSize, centerX, centerY, panX, panY, seatCol, canvasWidth, seatMaxWidth, seatMinWidth })
  175. if (!zoom)
  176. return
  177. this.boundaries = this.calc.getBoundaries(this.options.canvasWidth, this.options.canvasHeight, this.options.footerHeight, zoom.cellSize)
  178. const { targetX, targetY } = this.calc.clampOffset(zoom.panX, zoom.panY, this.boundaries)
  179. this.cellSize = cellSize
  180. this.panX = targetX
  181. this.panY = targetY
  182. }
  183. chooseSeatList() {
  184. const { SELECTED } = this.options.SEAT_STATUS
  185. const { STATUS_NAME } = this.options.SEAT_FIELDS
  186. const chooseList = this.seatList.filter((item) => {
  187. return item[STATUS_NAME] === SELECTED
  188. })
  189. chooseList.sort((a, b) => {
  190. return a.clickTime - b.clickTime
  191. })
  192. return chooseList
  193. }
  194. cancelSeat(seat) {
  195. this.calc.cancelSeat(seat)
  196. this.requestDraw()
  197. }
  198. /* ========== 孤座检测 ========== */
  199. validateIsolates() {
  200. if (this.options.isolateSeats === false)
  201. return 0
  202. let errNo = 0
  203. this.chooseSeatList().forEach((seat) => {
  204. errNo += this.calc.validateSeat(seat)
  205. })
  206. return errNo
  207. }
  208. /* -------------------- 工具 -------------------- */
  209. _afterDrawTimer() {
  210. if (this._timer)
  211. clearTimeout(this._timer)
  212. this._timer = setTimeout(() => {
  213. this.miniMapShow = false
  214. this.requestDraw()
  215. }, this.options.miniMapShowTime)
  216. }
  217. _animateTo(tx, ty) {
  218. const startX = this.panX
  219. const startY = this.panY
  220. const duration = 300
  221. const startTime = Date.now()
  222. const animate = () => {
  223. const elapsed = Date.now() - startTime
  224. const prog = Math.min(elapsed / duration, 1)
  225. const ease = 1 - (1 - prog) ** 3
  226. this.panX = startX + (tx - startX) * ease
  227. this.panY = startY + (ty - startY) * ease
  228. this.requestDraw()
  229. if (prog < 1)
  230. this.operator.raf(animate)
  231. }
  232. this.operator.raf(animate)
  233. }
  234. /* ---------- 深度合并 ---------- */
  235. _deepMerge(target, source) {
  236. // 如果目标或源不是对象,直接返回源
  237. if (typeof target !== 'object' || target === null || Array.isArray(target)) {
  238. return source
  239. }
  240. // 遍历源对象的每个属性
  241. for (const key in source) {
  242. if (Object.prototype.hasOwnProperty.call(source, key)) {
  243. const targetValue = target[key]
  244. const sourceValue = source[key]
  245. // 如果源属性是对象,递归合并
  246. if (typeof sourceValue === 'object' && sourceValue !== null && !Array.isArray(sourceValue)) {
  247. if (typeof targetValue !== 'object' || targetValue === null || Array.isArray(targetValue)) {
  248. target[key] = {}
  249. }
  250. this._deepMerge(target[key], sourceValue)
  251. }
  252. else {
  253. // 否则,直接覆盖目标属性
  254. target[key] = sourceValue
  255. }
  256. }
  257. }
  258. return target
  259. }
  260. }