Timeline.ts 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927
  1. import dayjs, { Dayjs } from 'dayjs'
  2. import { Event, IEvent } from './Event'
  3. import { Ressource, IRessource } from './Ressource'
  4. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  5. import { HorizontalResizer } from './components/horizontal-resizer'
  6. import { LitElement, html, customElement, property, css, TemplateResult } from 'lit-element';
  7. import { styleMap } from 'lit-html/directives/style-map';
  8. import syncronizeElementsScrolling from './utils/syncroScroll';
  9. import Selectable from './utils/selectable';
  10. interface TimelineOptions {
  11. ressources?: Array<IRessource>
  12. items?: Array<IEvent>
  13. }
  14. interface TimelineContent {
  15. ressources: Array<Ressource>
  16. items: Array<Event>
  17. }
  18. interface TimeInterval {
  19. start: number,
  20. end: number,
  21. slots: Array<Event>
  22. }
  23. type dayjsUnit = "y"|"M"|"d"|"h"|"m"|'s'
  24. type UnitLegend = {
  25. [k in dayjsUnit]: string;
  26. };
  27. interface legendItem {
  28. colspan:number
  29. title:string
  30. }
  31. // TODO define std zoom level
  32. // TODO add selectable Slot
  33. // TODO enable to rearrange between different component.
  34. @customElement('jc-timeline')
  35. class Timeline extends LitElement {
  36. static styles = css`
  37. body{
  38. font-family:Roboto;
  39. }
  40. div {
  41. box-sizing: border-box;
  42. }
  43. .jc-timeline-content,
  44. .jc-timeline-header{
  45. width:100%;
  46. position:relative;
  47. display: flex;
  48. flex-direction:row;
  49. height:max-content;
  50. align-items: stretch;
  51. }
  52. .jc-timeline-rows-title,
  53. .jc-timeline-rows > tr > td{
  54. padding: 8px;
  55. min-width:40px;
  56. }
  57. .jc-timeline-rows > tr > td {
  58. max-width:calc( var(--width) - 8px );
  59. padding: 0px;
  60. vertical-align:top;
  61. }
  62. .jc-timeline-rows > tr.empty > td{
  63. padding: 6px 0px 4px 8px;
  64. }
  65. i.jc-spacer {
  66. display:inline-block;
  67. width : 1rem;
  68. height: 1rem;
  69. position:relative;
  70. box-sizing: border-box;
  71. }
  72. i.jc-spacer:after{
  73. content: " ";
  74. position:absolute;
  75. background-repeat: no-repeat;
  76. background-size: 1.05rem;
  77. width: 1.05rem;
  78. height: 1.05rem;
  79. }
  80. .jc-spacer.extend,
  81. .jc-spacer.collapse {
  82. cursor:pointer;
  83. }
  84. i.jc-spacer.extend:after{
  85. background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iYmxhY2siIHdpZHRoPSIxOHB4IiBoZWlnaHQ9IjE4cHgiPjxwYXRoIGQ9Ik0wIDBoMjR2MjRIMFYweiIgZmlsbD0ibm9uZSIvPjxwYXRoIGQ9Ik0xOSAzSDVjLTEuMTEgMC0yIC45LTIgMnYxNGMwIDEuMS44OSAyIDIgMmgxNGMxLjEgMCAyLS45IDItMlY1YzAtMS4xLS45LTItMi0yem0wIDE2SDVWNWgxNHYxNHptLTgtMmgydi00aDR2LTJoLTRWN2gtMnY0SDd2Mmg0eiIvPjwvc3ZnPg==")
  86. }
  87. i.jc-spacer.collapse:after{
  88. background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0Ij48cGF0aCBkPSJNMCAwaDI0djI0SDB6IiBmaWxsPSJub25lIi8+PHBhdGggZD0iTTE5IDNINWMtMS4xIDAtMiAuOS0yIDJ2MTRjMCAxLjEuOSAyIDIgMmgxNGMxLjEgMCAyLS45IDItMlY1YzAtMS4xLS45LTItMi0yem0wIDE2SDVWNWgxNHYxNHpNNyAxMWgxMHYySDd6Ii8+PC9zdmc+")
  89. }
  90. .jc-timeline-rows > tr{
  91. box-sizing: border-box;
  92. white-space: nowrap;
  93. border: 1px solid grey;
  94. border-style: solid none;
  95. }
  96. .jc-timeline-rows,
  97. .jc-timeline-rows-title{
  98. width:var(--width, 200px);
  99. overflow: hidden;
  100. border-collapse:collapse;
  101. }
  102. .jc-timeline-grid-title-container,
  103. .jc-timeline-grid-container{
  104. position:relative;
  105. width: 600px;
  106. display: block;
  107. overflow: hidden;
  108. }
  109. .jc-timeline-grid-container{
  110. overflow-x: auto;
  111. }
  112. .jc-timeline-grid-title-container > table,
  113. .jc-timeline-grid-container > table {
  114. width:100%;
  115. table-layout: fixed;
  116. border-collapse: collapse;
  117. box-sizing: border-box;
  118. }
  119. .jc-timeline-grid-title-container {
  120. white-space: nowrap;
  121. cursor: grab;
  122. user-select: none; /* supported by Chrome and Opera */
  123. -webkit-user-select: none; /* Safari */
  124. -khtml-user-select: none; /* Konqueror HTML */
  125. -moz-user-select: none; /* Firefox */
  126. -ms-user-select: none; /* Internet Explorer/Edge */
  127. }
  128. .jc-timeline-grid-title:first-child > th{
  129. border-top:0;
  130. }
  131. .jc-timeline-grid-title:first-child > th:before,
  132. .jc-timeline-grid-title:first-child > th:last-child:after {
  133. content:" ";
  134. display: block;
  135. position:absolute;
  136. left:-1px;
  137. top:0px;
  138. height:calc( 100% - 8px);
  139. border-left: 2px solid white;
  140. z-index:2;
  141. }
  142. .jc-timeline-grid-title:first-child > th:last-child:after
  143. {
  144. left:auto;
  145. right:-1px;
  146. }
  147. .jc-timeline-grid-title:first-child:last-child >th{
  148. padding:8px 0;
  149. }
  150. .jc-timeline-grid-title:last-child > th{
  151. border-bottom:none;
  152. }
  153. .jc-timeline-grid-title > th,
  154. .jc-slot {
  155. height:100%;
  156. border: solid 1px lightgray;
  157. border-left-style: dotted;
  158. border-right:0;
  159. text-align: center;
  160. position:relative;
  161. box-sizing: border-box;
  162. }
  163. .jc-timeline-grid-title > th:last-child,
  164. .jc-slot:last-child{
  165. border-right:solid 1px lightgray;
  166. }
  167. .jc-timeline-grid-title > th,
  168. .jc-major-slot{
  169. border-left-style: solid;
  170. }
  171. .jc-timeslots{
  172. position:absolute;
  173. top:0px;
  174. left:0px;
  175. bottom:0px;
  176. overflow: hidden;
  177. }
  178. .jc-timeslot{
  179. position:absolute;
  180. white-space: nowrap;
  181. overflow-x:hidden;
  182. background-color:var(--default-background);
  183. color:#fff;
  184. border-radius:3px;
  185. padding:4px;
  186. margin:2px 0px;
  187. z-index:1;
  188. cursor:auto;
  189. }
  190. .jc-timeslot.empty{
  191. height:5px;
  192. padding:2px 2px;
  193. margin:0px;
  194. cursor:pointer;
  195. }
  196. .jc-timeslot.moving{
  197. opacity:0.7;
  198. cursor:grabbing;
  199. }
  200. .jc-timeslot.selected:before{
  201. border:solid 2px black;
  202. position:absolute;
  203. top:0;
  204. bottom:0;
  205. left:0;
  206. right:0;
  207. content:" ";
  208. }
  209. .jc-timeslot-resizer-start,
  210. .jc-timeslot-resizer-end{
  211. position:absolute;
  212. top:0;
  213. bottom:0;
  214. width:4px;
  215. min-width:4px;
  216. display:block;
  217. cursor: ew-resize;
  218. }
  219. .jc-timeslot-resizer-start,
  220. .jc-timeslot-resizer-end{
  221. display:block;
  222. }
  223. .jc-timeslot-resizer-start{
  224. left:0px;
  225. }
  226. .jc-timeslot-resizer-end{
  227. right:0px;
  228. }
  229. .jc-timeline-rows > tr >td{
  230. height:100%;
  231. }
  232. .jc-ressource{
  233. padding-top: 2px;
  234. height: calc( 100% - 8px);
  235. }
  236. .jc-ressource > span {
  237. pointer-events: none;
  238. }
  239. .jc-ressource.target{
  240. background-color: lightgrey;
  241. }
  242. .jc-ressource-selected{
  243. border:1px solid var(--default-background, SteelBlue);
  244. border-right:0;
  245. border-left:0;
  246. background-color:#4682b46b;
  247. }
  248. .jc-ressource-above{
  249. height:4px;
  250. }
  251. .jc-ressource-above.target{
  252. margin-left: calc( var(--depth) * 16px );
  253. background-color: var(--default-background, SteelBlue);
  254. border-radius: 0 0 0 4px;
  255. }
  256. .jc-ressource-below{
  257. height:4px;
  258. }
  259. .jc-ressource-below.target{
  260. margin-left: calc( var(--depth) * 16px);
  261. background-color: var(--default-background, SteelBlue);
  262. border-radius: 4px 0 0 0;
  263. }
  264. `;
  265. @property({ type: Array })
  266. private rows: Array<Ressource>
  267. @property({ type: Array })
  268. private items: Array<Event>
  269. private selectedList: Array<Selectable>
  270. @property({ type: Number })
  271. private ressourceWidth: number
  272. @property({ type: Object })
  273. private _start: Dayjs
  274. @property({ type: String })
  275. public get start(): string {
  276. return this._start.toISOString();
  277. }
  278. public set start(value: string){
  279. this._start = dayjs(value);
  280. this.updateLegend();
  281. }
  282. private _end: Dayjs
  283. @property({ type: String })
  284. public get end(): string {
  285. return this._end.toISOString();
  286. }
  287. public set end(value: string){
  288. this._end = dayjs(value);
  289. this.updateLegend();
  290. }
  291. private _slotDuration = 30; // in minute
  292. @property({ type: Number })
  293. public get slotDuration(): number {
  294. return this._slotDuration;
  295. }
  296. public set slotDuration(value: number) {
  297. this._slotDuration = value;
  298. this.updateLegend();
  299. }
  300. private _legendSpan = 2; // in slot count
  301. @property({ type: Number })
  302. public get legendSpan(): number {
  303. return this._legendSpan;
  304. }
  305. public set legendSpan(value: number) {
  306. this._legendSpan = value;
  307. this.updateLegend();
  308. }
  309. @property({ type: Number })
  310. private rowHeight = 32 // in px
  311. @property({ type: Number })
  312. private slotWidth = 20 // in px
  313. @property({ type: String })
  314. private rowsTitle: string
  315. private legendUnitFormat:UnitLegend = {"y":"YYYY","M":"MMMM","d":'D',"h":"H[h]","m":"m'",'s':"s[s]"}
  316. @property({ type: Array })
  317. private legend: Array<Array<legendItem>>;
  318. constructor(options: TimelineOptions = {}) {
  319. super()
  320. this.rows = options.ressources ? options.ressources.map(Ressource.toRessource) : [];
  321. this.items = options.items ? options.items.map(Event.toTimeSlot) : []
  322. this._start = dayjs().startOf("day");
  323. this._end = this._start.endOf("day");
  324. this.rowsTitle = "Ressources"
  325. this.ressourceWidth = 200
  326. this.selectedList = [];
  327. this.legend = [];
  328. this.defaultBackground = "";
  329. this.updateLegend();
  330. this.render();
  331. }
  332. @property({type:String})
  333. set defaultBackground(value: string) {
  334. this.style.setProperty("--default-background", value);
  335. }
  336. get defaultBackground(): string {
  337. return this.style.getPropertyValue("--default-background");
  338. }
  339. setLegendUnitFormatAll(legend:Partial<UnitLegend>):void{
  340. this.legendUnitFormat = {...this.legendUnitFormat, ...legend}
  341. this.updateLegend()
  342. }
  343. setLegendUnitFormat(unit:dayjsUnit, format:string):void{
  344. this.legendUnitFormat[unit] = format;
  345. this.updateLegend()
  346. }
  347. addRessources(list:Array<IRessource>):Array<Ressource | undefined>{
  348. return list.map(r=>this.addRessource(r));
  349. }
  350. // Ressource management
  351. addRessource(ressource: IRessource): Ressource | undefined {
  352. if (this.rows.filter(o => o.id == ressource.id).length > 0) {
  353. return
  354. }
  355. const r = Ressource.toRessource(ressource)
  356. if (r.parent !== undefined) {
  357. const idx = this.rows.indexOf(r.parent)
  358. if (idx > -1) {
  359. this.rows[idx].children.push(r);
  360. this.rows.splice(idx + 1, 0, r);
  361. } else {
  362. return
  363. }
  364. } else {
  365. this.rows = [...this.rows, r]
  366. }
  367. this.updateTimeslotPosition(r)
  368. return r;
  369. }
  370. removeRessourceById(id: string): TimelineContent {
  371. return this._removeRessourceById(id);
  372. }
  373. _removeRessourceById(id: string, depth = 0):TimelineContent{
  374. const output: TimelineContent = { ressources: [], items: [] };
  375. for (let i = 0; i < this.rows.length; i) {
  376. const ressource = this.rows[i];
  377. if (ressource.id === id) {
  378. output.ressources.push(ressource);
  379. // remove the top level children from it's parent.
  380. if (ressource.parent && depth === 0 ){
  381. ressource.parent.children = ressource.parent.children.filter(o=> o.id !== ressource.id)
  382. }
  383. this.rows.splice(i, 1);
  384. } else if (ressource.parentId === id) {
  385. const partialOutput = this._removeRessourceById(ressource.id, depth + 1);
  386. output.ressources.push(...partialOutput.ressources)
  387. output.items.push(...partialOutput.items)
  388. } else {
  389. i++
  390. }
  391. }
  392. // Recover deleted items
  393. output.items.push(...this.items.filter(i => i.ressourceId === id))
  394. // Update items list
  395. this.items = this.items.filter(i => i.ressourceId !== id)
  396. return output;
  397. }
  398. getRessources(): Array<Ressource> {
  399. return this.rows;
  400. }
  401. getRessourceFromId(id:string):Ressource | null{
  402. const tmp = this.rows.filter(r=>r.id===id)
  403. return tmp.length > 0 ? tmp[0] : null;
  404. }
  405. setRowsTitle(title: string):void {
  406. this.rowsTitle = title;
  407. }
  408. addTimeSlots(list:Array<IEvent>):Array<Event | undefined>{
  409. return list.map((e)=>this.addTimeSlot(e));
  410. }
  411. // TimeSlot management
  412. addTimeSlot(slot: IEvent): Event | undefined {
  413. if (this.items.filter(o => o.id == slot.id).length > 0) {
  414. return undefined
  415. }
  416. const ressource = this.rows.find(r => r.id === slot.ressourceId);
  417. if (ressource === undefined) {
  418. return undefined
  419. }
  420. const timeslot = Event.toTimeSlot(slot);
  421. this.items = [...this.items, timeslot]
  422. // Update timeslot status
  423. timeslot.isDisplayed = timeslot.end > this._start.toDate() || timeslot.start < this._end.toDate();
  424. this.updateTimeslotPosition(ressource)
  425. return timeslot;
  426. }
  427. removeTimeslotById(id: string): Array<Event> {
  428. const output = this.items.filter(o => o.id === id);
  429. this.items = this.items.filter(o => o.id !== id);
  430. return output
  431. }
  432. updateTimeslotById(id: string): Event | null {
  433. const output = this.removeTimeslotById(id)
  434. if (output.length > 0) {
  435. this.addTimeSlot(output[0])
  436. return output[0];
  437. } else {
  438. return null;
  439. }
  440. }
  441. private updateTimeslotPosition(ressource: Ressource): void {
  442. const timeslots = this.items.filter(i => i.ressourceId === ressource.id);
  443. if (timeslots.length === 0) {
  444. ressource.height = this.rowHeight + (ressource.collapseChildren ? 5:0);
  445. return
  446. }
  447. const start = this._start.toDate().getTime();
  448. const end = this._end.toDate().getTime();
  449. // List potential interval
  450. const points = [start, end]
  451. const populateInterval = (d: Date) => {
  452. const t = d.getTime()
  453. if (start < t && t < end && !points.includes(t)) {
  454. points.push(t)
  455. }
  456. }
  457. timeslots.forEach(element => {
  458. populateInterval(element.start);
  459. populateInterval(element.end);
  460. });
  461. points.sort();
  462. // Count maximum number of interval intersection
  463. const intervals: Array<TimeInterval> = []
  464. for (let i = 0; i < points.length - 1; i++) {
  465. const startTime = points[i];
  466. const endTime = points[i+1];
  467. intervals.push({
  468. start: points[i],
  469. end: points[i+1],
  470. slots: timeslots.filter(slot => (slot.start.getTime() <= startTime && endTime <= slot.end.getTime()))
  471. })
  472. }
  473. // Update rows height
  474. const lineCount = intervals.reduce((acc, interval) => Math.max(acc, interval.slots.length), 0);
  475. ressource.height = this.rowHeight * Math.max(lineCount, 1) + (ressource.collapseChildren ? 5:0); // to avoid collapse rows
  476. // Solve the offset positioning of all items
  477. const sortTimeslots = (a: Event, b: Event): number => {
  478. const t = a.start.getTime() - b.start.getTime();
  479. if (t === 0) {
  480. const tend = b.end.getTime() - a.end.getTime();
  481. return tend === 0 ? ('' + a.id).localeCompare(b.id) : tend
  482. }
  483. return t;
  484. }
  485. // Remove all items offset
  486. timeslots.forEach(slot => slot.offset = -1);
  487. timeslots.sort(sortTimeslots);
  488. timeslots[0].offset = 0;
  489. const potentialOffset: Array<number> = []
  490. for (let i = 0; i < lineCount; i++) {
  491. potentialOffset.push(i)
  492. }
  493. intervals.forEach(intervals => {
  494. intervals.slots.sort(sortTimeslots);
  495. const usedOffset = intervals.slots.map(o => o.offset).filter(i => i > -1);
  496. const availableOffset = potentialOffset.filter(i => !usedOffset.includes(i));
  497. intervals.slots.forEach(slot => {
  498. if (slot.offset === -1) {
  499. slot.offset = availableOffset.shift() || 0;
  500. }
  501. })
  502. })
  503. }
  504. getTimeSlots(): Array<Event> {
  505. return this.items;
  506. }
  507. updateLegend():void {
  508. const legend = [];
  509. const legendUnitList:Array<dayjsUnit> = [ "y","M","d","h","m",'s'];
  510. const legendMinUnitSpan = this.slotDuration * this.legendSpan;
  511. for (const legendUnit of legendUnitList) {
  512. let currentDate = dayjs(this._start);
  513. let nextColumn = currentDate.add(legendMinUnitSpan,"m");
  514. // Check is the starting and end date can accomodate fews cell of the given unit for this unit of time
  515. const isLegendPossible =
  516. // Enough time to fit 1 cell AND
  517. this._end.diff(this._start, legendUnit) > 0 &&
  518. // Starting & Ending date have different legend
  519. (nextColumn.format(this.legendUnitFormat[legendUnit])!==currentDate.format(this.legendUnitFormat[legendUnit])
  520. // OR 2 consecutive legends can accomodate at least 1 grid cell
  521. || currentDate.add(1,legendUnit).diff(currentDate,"m") >= legendMinUnitSpan)
  522. if(isLegendPossible){
  523. const row:Array<legendItem> = [];
  524. let i = 0;
  525. while(currentDate.isBefore(this._end)){
  526. i+=this.legendSpan;
  527. if(nextColumn.diff(currentDate,legendUnit) > 0 ){
  528. row.push({colspan:i,title:'' + currentDate.format(this.legendUnitFormat[legendUnit])})
  529. i = 0;
  530. currentDate = nextColumn;
  531. }
  532. nextColumn = nextColumn.add(legendMinUnitSpan,"m");
  533. }
  534. legend.push(row);
  535. }
  536. }
  537. this.legend = legend;
  538. }
  539. _handleResizeX(e: CustomEvent<number>):void {
  540. e.stopPropagation();
  541. this.ressourceWidth += e.detail
  542. if (this.ressourceWidth < 0) {
  543. this.ressourceWidth = 0
  544. }
  545. }
  546. _grabHeader(e: MouseEvent):void {
  547. const root = this.shadowRoot;
  548. if (root !== null) {
  549. const gridContainer = root.querySelector(".jc-timeline-grid-container") as HTMLBaseElement;
  550. const headerContainer = root.querySelector(".jc-timeline-grid-title-container") as HTMLBaseElement;
  551. let lastPosX = e.clientX;
  552. const scroll = function (e: MouseEvent) {
  553. const scrollLeft = (lastPosX - e.clientX)
  554. headerContainer.scrollLeft += scrollLeft
  555. gridContainer.scrollLeft += scrollLeft
  556. lastPosX = e.clientX
  557. }
  558. const mouseUpListener = function (_e: MouseEvent) {
  559. window.removeEventListener("mousemove", scroll)
  560. window.removeEventListener("mouseup", mouseUpListener)
  561. }
  562. window.addEventListener("mousemove", scroll)
  563. window.addEventListener("mouseup", mouseUpListener)
  564. }
  565. }
  566. _getEventResizerHandler(slot: Event, direction: "end" | "start") {
  567. return (evt: MouseEvent):void => {
  568. evt.stopPropagation();
  569. evt.preventDefault()
  570. const startPos = evt.clientX;
  571. const localSlot = slot
  572. const localDir = direction;
  573. const startDate = slot[direction];
  574. const resizeListener = (e: MouseEvent) => {
  575. const newDate = dayjs(startDate).add(Math.round((e.clientX - startPos) / this.slotWidth) * this.slotDuration, "m").toDate();
  576. if (direction === "start" ? (newDate < localSlot.end) : (localSlot.start < newDate)) {
  577. localSlot[localDir] = newDate;
  578. this.updateTimeslotById(slot.id);
  579. }
  580. }
  581. const mouseUpListener = (_e: MouseEvent):void => {
  582. window.removeEventListener("mousemove", resizeListener)
  583. window.removeEventListener("mouseup", mouseUpListener)
  584. localSlot.moving = false;
  585. this.updateTimeslotById(slot.id);
  586. }
  587. localSlot.moving = true
  588. window.addEventListener("mousemove", resizeListener)
  589. window.addEventListener("mouseup", mouseUpListener)
  590. }
  591. }
  592. _getEventGrabHandler(slot: Event, editable: boolean, ressourceEditable: boolean, callback: (e: MouseEvent, wasModified:boolean) => void) {
  593. return (evt: MouseEvent):void => {
  594. evt.stopPropagation();
  595. evt.preventDefault();
  596. const startPos = evt.clientX;
  597. let hasChanged = false;
  598. const localSlot = slot;
  599. // Register all current selected timeslot
  600. let localSlots: Array<Event> = this.selectedList.filter(s => s instanceof Event).map(s => s as Event);
  601. if (! localSlots.includes(localSlot)){
  602. localSlots=[localSlot]
  603. }
  604. const startDates = localSlots.map(slot => slot.start);
  605. const endDates = localSlots.map(slot => slot.end);
  606. const updatePosition = editable ? (e: MouseEvent) => {
  607. const changeTime = Math.round((e.clientX - startPos) / this.slotWidth) * this.slotDuration;
  608. return localSlots.map((slot, index) => {
  609. const prevStart = slot.start;
  610. slot.start = dayjs(startDates[index]).add(changeTime, "m").toDate();
  611. slot.end = dayjs(endDates[index]).add(changeTime, "m").toDate();
  612. return prevStart.getTime()!==slot.start.getTime()
  613. }).reduce((prev,curr)=>prev || curr)
  614. } : (_e: MouseEvent) => { return false };
  615. const updateRessource = ressourceEditable ? (e: MouseEvent) => {
  616. const rowId = this.shadowRoot?.elementsFromPoint(e.clientX, e.clientY)
  617. .find((e) => e.tagName == "TD")?.parentElement?.getAttribute('row-id');
  618. if (rowId) {
  619. const ressourceId = this.rows[Number(rowId)].id;
  620. if (ressourceId !== localSlot.ressourceId) {
  621. const oldRessource = this.getRessourceFromId(localSlot.ressourceId) as Ressource;
  622. localSlot.ressourceId = ressourceId;
  623. this.updateTimeslotPosition(oldRessource);
  624. return true;
  625. }
  626. }
  627. return false;
  628. } : (_e: MouseEvent) => { return false };
  629. const moveListener = (e: MouseEvent) => {
  630. // force the handling of potential position move
  631. const a = updatePosition(e);
  632. if (updateRessource(e) || a) {
  633. hasChanged = true;
  634. this.updateTimeslotById(localSlot.id);
  635. }
  636. }
  637. const mouseUpListener = (e: MouseEvent) => {
  638. window.removeEventListener("mousemove", moveListener);
  639. window.removeEventListener("mouseup", mouseUpListener);
  640. localSlot.moving = false;
  641. this.updateTimeslotById(slot.id);
  642. callback(e,hasChanged);
  643. }
  644. localSlot.moving = true;
  645. window.addEventListener("mousemove", moveListener)
  646. window.addEventListener("mouseup", mouseUpListener)
  647. }
  648. }
  649. private _clearSelectedItems(){
  650. this.selectedList.map(selectable => {
  651. selectable.selected = false;
  652. this.updateTimeslotById(selectable.id)
  653. });
  654. this.selectedList = []
  655. }
  656. private _clearSelectionhandler = (_e: MouseEvent)=>{
  657. this._clearSelectedItems()
  658. window.removeEventListener("click",this._clearSelectionhandler);
  659. }
  660. private _getEventClickHandler(clickedItem: Selectable) {
  661. const item = clickedItem;
  662. return (e: MouseEvent, wasModified = false) => {
  663. e.stopPropagation()
  664. const idx = this.selectedList.indexOf(item)
  665. if (idx > -1) {
  666. if(! wasModified){
  667. if (e.ctrlKey) {
  668. this.selectedList.splice(idx, 1);
  669. item.selected = false;
  670. this.updateTimeslotById(item.id)
  671. } else {
  672. this._clearSelectedItems()
  673. }
  674. }
  675. } else {
  676. if (this.selectedList.length > 0 && ! e.ctrlKey ) {
  677. this._clearSelectedItems()
  678. }
  679. item.selected = true;
  680. this.selectedList.push(item)
  681. this.updateTimeslotById(item.id)
  682. }
  683. const myEvent = new CustomEvent('item-selected', {
  684. detail: { items: this.selectedList },
  685. bubbles: true,
  686. composed: true
  687. });
  688. this.dispatchEvent(myEvent);
  689. };
  690. }
  691. firstUpdated():void {
  692. const root = this.shadowRoot;
  693. if (root !== null) {
  694. const gridContainer = root.querySelector(".jc-timeline-grid-container") as HTMLBaseElement;
  695. syncronizeElementsScrolling([gridContainer,
  696. root.querySelector(".jc-timeline-grid-title-container") as HTMLBaseElement], "h")
  697. syncronizeElementsScrolling([gridContainer,
  698. root.querySelector(".jc-timeline-rows") as HTMLBaseElement], "v")
  699. }
  700. if (this.defaultBackground === "") {
  701. this.style.setProperty("--default-background", "SteelBlue");
  702. }
  703. }
  704. // RENDERING
  705. renderTimeslot(slot: Event): TemplateResult {
  706. if (!slot.isDisplayed) {
  707. return html``
  708. }
  709. let rowTop = 0
  710. let ressource: Ressource;
  711. let i: number;
  712. for (i = 0; i < this.rows.length && this.rows[i].id !== slot.ressourceId; i++) {
  713. ressource = this.rows[i];
  714. if (ressource.show){
  715. rowTop += ressource.height ? ressource.height : this.rowHeight;
  716. }
  717. }
  718. ressource = this.rows[i];
  719. const minute2pixel = this.slotWidth / this.slotDuration;
  720. const left = dayjs(slot.start).diff(this._start, "m") * minute2pixel ;
  721. const right = - dayjs(slot.end).diff(this._end, "m") * minute2pixel ;
  722. const style = {
  723. height: this.rowHeight - 4 + "px",
  724. top: rowTop + slot.offset * this.rowHeight + "px",
  725. left: left + "px",
  726. right: right + "px",
  727. backgroundColor:""
  728. };
  729. const bgColor = slot.bgColor ? slot.bgColor : ressource.eventBgColor;
  730. if (bgColor) {
  731. style.backgroundColor = bgColor;
  732. }
  733. // Show collapsed ressource
  734. if (! ressource.show) {
  735. style.height = ""
  736. style.top = rowTop - 6 + "px";
  737. return html`<div class="jc-timeslot empty" style="${styleMap(style)}"></div>`
  738. }
  739. let content: TemplateResult = html`${slot.title}`
  740. const resizer = slot.editable === null ? ressource.eventEditable : slot.editable;
  741. const editableRessource = slot.ressourceEditable === null ? ressource.eventRessourceEditable : slot.ressourceEditable;
  742. if (resizer) {
  743. content = html`<div class="jc-timeslot-resizer-start" @mousedown="${this._getEventResizerHandler(slot, "start")}"></div>${content}
  744. <div class="jc-timeslot-resizer-end" @mousedown="${this._getEventResizerHandler(slot, "end")}"></div>`;
  745. }
  746. return html`<div class="jc-timeslot ${slot.moving ? "moving" : ""} ${slot.selected ? "selected" : ""}"
  747. start="${slot.start.getHours()}"
  748. end="${slot.end.getHours()}"
  749. style="${styleMap(style)}"
  750. @mousedown="${this._getEventGrabHandler(slot, resizer, editableRessource, this._getEventClickHandler(slot))}"
  751. >${content}</div>`;
  752. }
  753. _getCollapseRessourceHandler(item:Ressource):(e:MouseEvent)=>void{
  754. return (_e:MouseEvent) => {
  755. item.collapseChildren = ! item.collapseChildren;
  756. this.updateTimeslotPosition(item);
  757. // Force rows refresh TODO improve this rerendering
  758. this.rows = [...this.rows];
  759. };
  760. }
  761. _onRessourceDragStart(item:Ressource):(event:DragEvent)=>void{
  762. return (event:DragEvent):void=>{
  763. event.dataTransfer?.setData("text", item.id);
  764. }
  765. }
  766. _onRessourceDragEnter(event:DragEvent):void{
  767. if (event.target instanceof HTMLElement){
  768. const tgt = event.target;
  769. tgt.classList.add("target");
  770. }
  771. }
  772. _onRessourceDragLeave(event:DragEvent):void{
  773. if (event.target instanceof HTMLElement){
  774. event.target.classList.remove("target");
  775. }
  776. }
  777. _onRessourceDrop(event:DragEvent):void{
  778. event.preventDefault()
  779. if (event.target instanceof HTMLElement){
  780. event.target.classList.remove("target");
  781. const srcId = event.dataTransfer?.getData("text");
  782. const destinationId = event.target.parentElement?.getAttribute("ressourceId")
  783. if (srcId && destinationId && (destinationId !== srcId) ){
  784. // Check if destination is not child of parent
  785. const src = this.getRessourceFromId(srcId) as Ressource;
  786. const destination = this.getRessourceFromId(destinationId) as Ressource;
  787. if(destination.childOf(src)){
  788. return
  789. }
  790. // Remove src item from the current Ressource
  791. const movedContent = this.removeRessourceById(src.id);
  792. // Update the moved ressource position
  793. if (event.target.classList.contains("jc-ressource")){
  794. movedContent.ressources[0].parent = destination;
  795. }else {
  796. movedContent.ressources[0].parent = destination.parent;
  797. let idx = this.rows.findIndex(v=>v.id === destinationId);
  798. if (event.target.classList.contains("jc-ressource-below")){
  799. idx += 1;
  800. while((idx < this.rows.length)
  801. && this.rows[idx].childOf(destination)){
  802. idx += 1;
  803. }
  804. }
  805. const arr = this.rows
  806. this.rows = [...arr.splice(0, idx), src, ...arr];
  807. }
  808. // Add moved children and associated slots
  809. this.addRessources(movedContent.ressources);
  810. this.addTimeSlots(movedContent.items);
  811. }
  812. }
  813. }
  814. renderRessource(item: Ressource): TemplateResult {
  815. const depth = item.depth;
  816. const style = `--depth:${depth};` + (item.height ? `height:${item.height}px;` : "");
  817. const hasChild = item.children.length > 0;
  818. const collapseHandler = this._getCollapseRessourceHandler(item);
  819. return html`<tr>
  820. <td class="${item.selected ? "jc-ressource-selected":""}" style="${style}" ressourceId="${item.id}" @click="${this._getEventClickHandler(item)}">
  821. <div class="jc-ressource-above"></div>
  822. <div class="jc-ressource" draggable="true" @dragstart="${this._onRessourceDragStart(item)}">
  823. ${Array(depth).fill(0).map(_i => html`<i class="jc-spacer"></i>`)}${hasChild ? html`<i class="jc-spacer ${item.collapseChildren ? "extend" : "collapse"}" @click="${collapseHandler}"></i>` : html`<i class="jc-spacer"></i>`}
  824. <span>${item.title}</span>
  825. </div>
  826. <div class="jc-ressource-below"></div>
  827. </td>
  828. </tr>`;
  829. }
  830. renderGridRow(columns: Array<Dayjs>, rowId = -1, height = 30): TemplateResult {
  831. return html`<tr row-id="${rowId}">${columns.map((d,i) => html`<td style="height:${height}px;" class="jc-slot ${(i % this.legendSpan) === 0 ? "jc-major-slot" :""}" start="${d.toISOString()}">&nbsp;</td>`)}</tr>`
  832. }
  833. render():TemplateResult {
  834. const nCol = Math.floor(this._end.diff(this._start, 'm') / this.slotDuration) + 1;
  835. const columns: Array<Dayjs> = []
  836. for (let i = 0; i < nCol; i++) {
  837. columns.push(this._start.add(this.slotDuration * i, 'm'))
  838. }
  839. const displayedRows = this.rows.map((r,i)=>{return{i:i,r:r}}).filter(o=>o.r.show);
  840. return html`
  841. <div class="jc-timeline-header">
  842. <div class="jc-timeline-rows-title" style=${styleMap({ minWidth: this.ressourceWidth + "px", width: this.ressourceWidth + "px" })}>${this.rowsTitle}</div>
  843. <horizontal-resizer @resize-x="${this._handleResizeX}"></horizontal-resizer>
  844. <div class="jc-timeline-grid-title-container">
  845. <table @mousedown="${this._grabHeader}" style="width:${nCol * this.slotWidth}px;">
  846. <colgroup>${columns.map(_o=>html`<col style="min-width:${this.slotWidth}px">`)}</colgroup>
  847. <tbody>
  848. ${this.legend.map(arr=>html`<tr class="jc-timeline-grid-title">${arr.map(o=>html`<th colspan="${o.colspan}">${o.title}</th>`)}</tr>`)}
  849. </tbody>
  850. </table>
  851. </div>
  852. </div>
  853. <div class="jc-timeline-content">
  854. <table class="jc-timeline-rows"
  855. style="${styleMap({ "--width": this.ressourceWidth + "px" })}"
  856. @dragover="${(e:DragEvent)=>e.preventDefault()}"
  857. @dragenter="${this._onRessourceDragEnter}"
  858. @dragleave="${this._onRessourceDragLeave}"
  859. @drop="${this._onRessourceDrop}">
  860. ${this.rows.length > 0 ? displayedRows.map(o=>this.renderRessource(o.r)) : html`<tr class="empty"><td>No ressource</td></tr>`}
  861. </table>
  862. <horizontal-resizer @resize-x="${this._handleResizeX}"></horizontal-resizer>
  863. <div class="jc-timeline-grid-container">
  864. <table style="width:${nCol * this.slotWidth}px;">
  865. <colgroup>${columns.map(_o=>html`<col style="min-width:${this.slotWidth}px">`)}</colgroup>
  866. <tbody>
  867. ${this.rows.length > 0 ? displayedRows.map(o => this.renderGridRow(columns, o.i, o.r.height)) : this.renderGridRow(columns)}
  868. </tbody>
  869. </table>
  870. <div class="jc-timeslots" style="width:${nCol * this.slotWidth}px;">
  871. ${this.items.map(slot => this.renderTimeslot(slot))}
  872. </div>
  873. </div>
  874. </div>
  875. `;
  876. }
  877. }
  878. export default Timeline;