| b69ab31 | | | 1 | /** |
| b69ab31 | | | 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. |
| b69ab31 | | | 3 | * |
| b69ab31 | | | 4 | * This source code is licensed under the MIT license found in the |
| b69ab31 | | | 5 | * LICENSE file in the root directory of this source tree. |
| b69ab31 | | | 6 | */ |
| b69ab31 | | | 7 | |
| b69ab31 | | | 8 | import type {Hash} from '../types'; |
| b69ab31 | | | 9 | |
| b69ab31 | | | 10 | import {assert} from '../utils'; |
| b69ab31 | | | 11 | |
| b69ab31 | | | 12 | /* eslint no-bitwise: 0 */ |
| b69ab31 | | | 13 | /* Translated from fbcode/eden/scm/lib/renderdag/src/render.rs */ |
| b69ab31 | | | 14 | |
| b69ab31 | | | 15 | enum ColumnType { |
| b69ab31 | | | 16 | Empty = 0, |
| b69ab31 | | | 17 | Blocked = 1, |
| b69ab31 | | | 18 | Reserved = 2, |
| b69ab31 | | | 19 | Ancestor = 3, |
| b69ab31 | | | 20 | Parent = 4, |
| b69ab31 | | | 21 | } |
| b69ab31 | | | 22 | |
| b69ab31 | | | 23 | type ColumnProps = |
| b69ab31 | | | 24 | | { |
| b69ab31 | | | 25 | type: ColumnType.Empty | ColumnType.Blocked; |
| b69ab31 | | | 26 | hash: undefined; |
| b69ab31 | | | 27 | } |
| b69ab31 | | | 28 | | { |
| b69ab31 | | | 29 | type: ColumnType.Reserved | ColumnType.Ancestor | ColumnType.Parent; |
| b69ab31 | | | 30 | hash: Hash; |
| b69ab31 | | | 31 | }; |
| b69ab31 | | | 32 | |
| b69ab31 | | | 33 | export class Column { |
| b69ab31 | | | 34 | constructor(private inner: ColumnProps = {type: ColumnType.Empty, hash: undefined}) {} |
| b69ab31 | | | 35 | |
| b69ab31 | | | 36 | static empty(): Column { |
| b69ab31 | | | 37 | return new Column(); |
| b69ab31 | | | 38 | } |
| b69ab31 | | | 39 | |
| b69ab31 | | | 40 | get type(): ColumnType { |
| b69ab31 | | | 41 | return this.inner.type; |
| b69ab31 | | | 42 | } |
| b69ab31 | | | 43 | |
| b69ab31 | | | 44 | get hash(): undefined | Hash { |
| b69ab31 | | | 45 | return this.inner.hash; |
| b69ab31 | | | 46 | } |
| b69ab31 | | | 47 | |
| b69ab31 | | | 48 | matches(n: Hash): boolean { |
| b69ab31 | | | 49 | return this.hash === n; |
| b69ab31 | | | 50 | } |
| b69ab31 | | | 51 | |
| b69ab31 | | | 52 | isEmpty(): boolean { |
| b69ab31 | | | 53 | return this.type === ColumnType.Empty; |
| b69ab31 | | | 54 | } |
| b69ab31 | | | 55 | |
| b69ab31 | | | 56 | variant(): number { |
| b69ab31 | | | 57 | return this.type; |
| b69ab31 | | | 58 | } |
| b69ab31 | | | 59 | |
| b69ab31 | | | 60 | mergeColumn(other: Column): Column { |
| b69ab31 | | | 61 | return other.variant() > this.variant() ? other : this; |
| b69ab31 | | | 62 | } |
| b69ab31 | | | 63 | |
| b69ab31 | | | 64 | reset(): Column { |
| b69ab31 | | | 65 | return this.type === ColumnType.Blocked ? Column.empty() : this; |
| b69ab31 | | | 66 | } |
| b69ab31 | | | 67 | |
| b69ab31 | | | 68 | toNodeLine(): NodeLine { |
| b69ab31 | | | 69 | switch (this.type) { |
| b69ab31 | | | 70 | case ColumnType.Ancestor: |
| b69ab31 | | | 71 | return NodeLine.Ancestor; |
| b69ab31 | | | 72 | case ColumnType.Parent: |
| b69ab31 | | | 73 | return NodeLine.Parent; |
| b69ab31 | | | 74 | default: |
| b69ab31 | | | 75 | return NodeLine.Blank; |
| b69ab31 | | | 76 | } |
| b69ab31 | | | 77 | } |
| b69ab31 | | | 78 | |
| b69ab31 | | | 79 | toLinkLine(): LinkLine { |
| b69ab31 | | | 80 | switch (this.type) { |
| b69ab31 | | | 81 | case ColumnType.Ancestor: |
| b69ab31 | | | 82 | return LinkLine.from(LinkLine.VERT_ANCESTOR); |
| b69ab31 | | | 83 | case ColumnType.Parent: |
| b69ab31 | | | 84 | return LinkLine.from(LinkLine.VERT_PARENT); |
| b69ab31 | | | 85 | default: |
| b69ab31 | | | 86 | return LinkLine.empty(); |
| b69ab31 | | | 87 | } |
| b69ab31 | | | 88 | } |
| b69ab31 | | | 89 | |
| b69ab31 | | | 90 | toPadLine(): PadLine { |
| b69ab31 | | | 91 | switch (this.type) { |
| b69ab31 | | | 92 | case ColumnType.Ancestor: |
| b69ab31 | | | 93 | return PadLine.Ancestor; |
| b69ab31 | | | 94 | case ColumnType.Parent: |
| b69ab31 | | | 95 | return PadLine.Parent; |
| b69ab31 | | | 96 | default: |
| b69ab31 | | | 97 | return PadLine.Blank; |
| b69ab31 | | | 98 | } |
| b69ab31 | | | 99 | } |
| b69ab31 | | | 100 | } |
| b69ab31 | | | 101 | |
| b69ab31 | | | 102 | class Columns { |
| b69ab31 | | | 103 | public inner: Array<Column>; |
| b69ab31 | | | 104 | |
| b69ab31 | | | 105 | constructor(columns?: Array<Column>) { |
| b69ab31 | | | 106 | this.inner = columns ?? []; |
| b69ab31 | | | 107 | } |
| b69ab31 | | | 108 | |
| b69ab31 | | | 109 | find(node: Hash): number | undefined { |
| b69ab31 | | | 110 | const index = this.inner.findIndex(column => column.matches(node)); |
| b69ab31 | | | 111 | return index >= 0 ? index : undefined; |
| b69ab31 | | | 112 | } |
| b69ab31 | | | 113 | |
| b69ab31 | | | 114 | findEmpty(index?: number): number | undefined { |
| b69ab31 | | | 115 | if (index != null && this.inner.at(index)?.isEmpty()) { |
| b69ab31 | | | 116 | return index; |
| b69ab31 | | | 117 | } |
| b69ab31 | | | 118 | return this.firstEmpty(); |
| b69ab31 | | | 119 | } |
| b69ab31 | | | 120 | |
| b69ab31 | | | 121 | firstEmpty(): number | undefined { |
| b69ab31 | | | 122 | const index = this.inner.findIndex(column => column.isEmpty()); |
| b69ab31 | | | 123 | return index >= 0 ? index : undefined; |
| b69ab31 | | | 124 | } |
| b69ab31 | | | 125 | |
| b69ab31 | | | 126 | newEmpty(): number { |
| b69ab31 | | | 127 | const columns = this.inner; |
| b69ab31 | | | 128 | columns.push(Column.empty()); |
| b69ab31 | | | 129 | return columns.length - 1; |
| b69ab31 | | | 130 | } |
| b69ab31 | | | 131 | |
| b69ab31 | | | 132 | convertAncestorToParent() { |
| b69ab31 | | | 133 | const columns = this.inner; |
| b69ab31 | | | 134 | for (let i = 0; i < columns.length; i++) { |
| b69ab31 | | | 135 | const {type, hash} = columns[i]; |
| b69ab31 | | | 136 | if (type === ColumnType.Ancestor && hash != null) { |
| b69ab31 | | | 137 | columns[i] = new Column({type: ColumnType.Parent, hash}); |
| b69ab31 | | | 138 | } |
| b69ab31 | | | 139 | } |
| b69ab31 | | | 140 | } |
| b69ab31 | | | 141 | |
| b69ab31 | | | 142 | reset(): void { |
| b69ab31 | | | 143 | let columns = this.inner; |
| b69ab31 | | | 144 | columns = columns.map(column => column.reset()); |
| b69ab31 | | | 145 | while (columns.at(-1)?.isEmpty()) { |
| b69ab31 | | | 146 | columns.pop(); |
| b69ab31 | | | 147 | } |
| b69ab31 | | | 148 | this.inner = columns; |
| b69ab31 | | | 149 | } |
| b69ab31 | | | 150 | |
| b69ab31 | | | 151 | merge(index: number, column: Column) { |
| b69ab31 | | | 152 | const columns = this.inner; |
| b69ab31 | | | 153 | columns[index] = columns[index].mergeColumn(column); |
| b69ab31 | | | 154 | } |
| b69ab31 | | | 155 | |
| b69ab31 | | | 156 | swap(index1: number, index2: number) { |
| b69ab31 | | | 157 | if (index1 !== index2) { |
| b69ab31 | | | 158 | const column1 = this.inner[index1]; |
| b69ab31 | | | 159 | const column2 = this.inner[index2]; |
| b69ab31 | | | 160 | this.inner[index1] = column2; |
| b69ab31 | | | 161 | this.inner[index2] = column1; |
| b69ab31 | | | 162 | } |
| b69ab31 | | | 163 | } |
| b69ab31 | | | 164 | } |
| b69ab31 | | | 165 | |
| b69ab31 | | | 166 | export enum AncestorType { |
| b69ab31 | | | 167 | Ancestor = 'Ancestor', |
| b69ab31 | | | 168 | Parent = 'Parent', |
| b69ab31 | | | 169 | Anonymous = 'Anonymous', |
| b69ab31 | | | 170 | } |
| b69ab31 | | | 171 | |
| b69ab31 | | | 172 | type AncestorProps = |
| b69ab31 | | | 173 | | { |
| b69ab31 | | | 174 | type: AncestorType.Ancestor | AncestorType.Parent; |
| b69ab31 | | | 175 | hash: Hash; |
| b69ab31 | | | 176 | } |
| b69ab31 | | | 177 | | { |
| b69ab31 | | | 178 | type: AncestorType.Anonymous; |
| b69ab31 | | | 179 | hash: undefined; |
| b69ab31 | | | 180 | }; |
| b69ab31 | | | 181 | |
| b69ab31 | | | 182 | export class Ancestor { |
| b69ab31 | | | 183 | constructor(private inner: AncestorProps = {type: AncestorType.Anonymous, hash: undefined}) {} |
| b69ab31 | | | 184 | |
| b69ab31 | | | 185 | toColumn(): Column { |
| b69ab31 | | | 186 | switch (this.inner.type) { |
| b69ab31 | | | 187 | case AncestorType.Ancestor: |
| b69ab31 | | | 188 | return new Column({type: ColumnType.Ancestor, hash: this.inner.hash}); |
| b69ab31 | | | 189 | case AncestorType.Parent: |
| b69ab31 | | | 190 | return new Column({type: ColumnType.Parent, hash: this.inner.hash}); |
| b69ab31 | | | 191 | case AncestorType.Anonymous: |
| b69ab31 | | | 192 | return new Column({type: ColumnType.Blocked, hash: undefined}); |
| b69ab31 | | | 193 | } |
| b69ab31 | | | 194 | } |
| b69ab31 | | | 195 | |
| b69ab31 | | | 196 | id(): Hash | undefined { |
| b69ab31 | | | 197 | return this.inner.hash; |
| b69ab31 | | | 198 | } |
| b69ab31 | | | 199 | |
| b69ab31 | | | 200 | isDirect(): boolean { |
| b69ab31 | | | 201 | return this.inner.type !== AncestorType.Ancestor; |
| b69ab31 | | | 202 | } |
| b69ab31 | | | 203 | |
| b69ab31 | | | 204 | toLinkLine(direct: LinkLine, indirect: LinkLine): LinkLine { |
| b69ab31 | | | 205 | return this.isDirect() ? direct : indirect; |
| b69ab31 | | | 206 | } |
| b69ab31 | | | 207 | } |
| b69ab31 | | | 208 | |
| b69ab31 | | | 209 | type AncestorColumnBoundsProps = { |
| b69ab31 | | | 210 | target: number; |
| b69ab31 | | | 211 | minAncestor: number; |
| b69ab31 | | | 212 | minParent: number; |
| b69ab31 | | | 213 | maxParent: number; |
| b69ab31 | | | 214 | maxAncestor: number; |
| b69ab31 | | | 215 | }; |
| b69ab31 | | | 216 | |
| b69ab31 | | | 217 | export class AncestorColumnBounds { |
| b69ab31 | | | 218 | constructor(private inner: AncestorColumnBoundsProps) {} |
| b69ab31 | | | 219 | |
| b69ab31 | | | 220 | static new(columns: Array<[number, Ancestor]>, target: number): AncestorColumnBounds | undefined { |
| b69ab31 | | | 221 | if (columns.length === 0) { |
| b69ab31 | | | 222 | return undefined; |
| b69ab31 | | | 223 | } |
| b69ab31 | | | 224 | const ancestorNumbers = [target, ...columns.map(([index]) => index)]; |
| b69ab31 | | | 225 | const parentNumbers = [target, ...columns.filter(([, a]) => a.isDirect()).map(([i]) => i)]; |
| b69ab31 | | | 226 | const minAncestor = Math.min(...ancestorNumbers); |
| b69ab31 | | | 227 | const maxAncestor = Math.max(...ancestorNumbers); |
| b69ab31 | | | 228 | const minParent = Math.min(...parentNumbers); |
| b69ab31 | | | 229 | const maxParent = Math.max(...parentNumbers); |
| b69ab31 | | | 230 | return new AncestorColumnBounds({ |
| b69ab31 | | | 231 | target, |
| b69ab31 | | | 232 | minAncestor, |
| b69ab31 | | | 233 | minParent, |
| b69ab31 | | | 234 | maxParent, |
| b69ab31 | | | 235 | maxAncestor, |
| b69ab31 | | | 236 | }); |
| b69ab31 | | | 237 | } |
| b69ab31 | | | 238 | |
| b69ab31 | | | 239 | get minAncestor(): number { |
| b69ab31 | | | 240 | return this.inner.minAncestor; |
| b69ab31 | | | 241 | } |
| b69ab31 | | | 242 | |
| b69ab31 | | | 243 | get minParent(): number { |
| b69ab31 | | | 244 | return this.inner.minParent; |
| b69ab31 | | | 245 | } |
| b69ab31 | | | 246 | |
| b69ab31 | | | 247 | get maxParent(): number { |
| b69ab31 | | | 248 | return this.inner.maxParent; |
| b69ab31 | | | 249 | } |
| b69ab31 | | | 250 | |
| b69ab31 | | | 251 | get maxAncestor(): number { |
| b69ab31 | | | 252 | return this.inner.maxAncestor; |
| b69ab31 | | | 253 | } |
| b69ab31 | | | 254 | |
| b69ab31 | | | 255 | get target(): number { |
| b69ab31 | | | 256 | return this.inner.target; |
| b69ab31 | | | 257 | } |
| b69ab31 | | | 258 | |
| b69ab31 | | | 259 | *range(): Iterable<number> { |
| b69ab31 | | | 260 | for (let i = this.minAncestor + 1; i < this.maxAncestor; ++i) { |
| b69ab31 | | | 261 | yield i; |
| b69ab31 | | | 262 | } |
| b69ab31 | | | 263 | } |
| b69ab31 | | | 264 | |
| b69ab31 | | | 265 | horizontalLine(index: number): LinkLine { |
| b69ab31 | | | 266 | if (index === this.target) { |
| b69ab31 | | | 267 | return LinkLine.empty(); |
| b69ab31 | | | 268 | } else if (index > this.minParent && index < this.maxParent) { |
| b69ab31 | | | 269 | return LinkLine.from(LinkLine.HORIZ_PARENT); |
| b69ab31 | | | 270 | } else if (index > this.minAncestor && index < this.maxAncestor) { |
| b69ab31 | | | 271 | return LinkLine.from(LinkLine.HORIZ_ANCESTOR); |
| b69ab31 | | | 272 | } else { |
| b69ab31 | | | 273 | return LinkLine.empty(); |
| b69ab31 | | | 274 | } |
| b69ab31 | | | 275 | } |
| b69ab31 | | | 276 | } |
| b69ab31 | | | 277 | |
| b69ab31 | | | 278 | export class LinkLine { |
| b69ab31 | | | 279 | constructor(public value = 0) {} |
| b69ab31 | | | 280 | |
| b69ab31 | | | 281 | /** This cell contains a horizontal line that connects to a parent. */ |
| b69ab31 | | | 282 | static HORIZ_PARENT = 1 << 0; |
| b69ab31 | | | 283 | /** This cell contains a horizontal line that connects to an ancestor. */ |
| b69ab31 | | | 284 | static HORIZ_ANCESTOR = 1 << 1; |
| b69ab31 | | | 285 | /** The descendent of this cell is connected to the parent. */ |
| b69ab31 | | | 286 | static VERT_PARENT = 1 << 2; |
| b69ab31 | | | 287 | /** The descendent of this cell is connected to an ancestor. */ |
| b69ab31 | | | 288 | static VERT_ANCESTOR = 1 << 3; |
| b69ab31 | | | 289 | /** The parent of this cell is linked in this link row and the child is to the left. */ |
| b69ab31 | | | 290 | static LEFT_FORK_PARENT = 1 << 4; |
| b69ab31 | | | 291 | /** The ancestor of this cell is linked in this link row and the child is to the left. */ |
| b69ab31 | | | 292 | static LEFT_FORK_ANCESTOR = 1 << 5; |
| b69ab31 | | | 293 | /** The parent of this cell is linked in this link row and the child is to the right. */ |
| b69ab31 | | | 294 | static RIGHT_FORK_PARENT = 1 << 6; |
| b69ab31 | | | 295 | /** The ancestor of this cell is linked in this link row and the child is to the right. */ |
| b69ab31 | | | 296 | static RIGHT_FORK_ANCESTOR = 1 << 7; |
| b69ab31 | | | 297 | /** The child of this cell is linked to parents on the left. */ |
| b69ab31 | | | 298 | static LEFT_MERGE_PARENT = 1 << 8; |
| b69ab31 | | | 299 | /** The child of this cell is linked to ancestors on the left. */ |
| b69ab31 | | | 300 | static LEFT_MERGE_ANCESTOR = 1 << 9; |
| b69ab31 | | | 301 | /** The child of this cell is linked to parents on the right. */ |
| b69ab31 | | | 302 | static RIGHT_MERGE_PARENT = 1 << 10; |
| b69ab31 | | | 303 | /** The child of this cell is linked to ancestors on the right. */ |
| b69ab31 | | | 304 | static RIGHT_MERGE_ANCESTOR = 1 << 11; |
| b69ab31 | | | 305 | /** |
| b69ab31 | | | 306 | * The target node of this link line is the child of this column. |
| b69ab31 | | | 307 | * This disambiguates between the node that is connected in this link line, |
| b69ab31 | | | 308 | * and other nodes that are also connected vertically. |
| b69ab31 | | | 309 | */ |
| b69ab31 | | | 310 | static CHILD = 1 << 12; |
| b69ab31 | | | 311 | |
| b69ab31 | | | 312 | static HORIZONTAL = LinkLine.HORIZ_PARENT | LinkLine.HORIZ_ANCESTOR; |
| b69ab31 | | | 313 | static VERTICAL = LinkLine.VERT_PARENT | LinkLine.VERT_ANCESTOR; |
| b69ab31 | | | 314 | static LEFT_FORK = LinkLine.LEFT_FORK_PARENT | LinkLine.LEFT_FORK_ANCESTOR; |
| b69ab31 | | | 315 | static RIGHT_FORK = LinkLine.RIGHT_FORK_PARENT | LinkLine.RIGHT_FORK_ANCESTOR; |
| b69ab31 | | | 316 | static LEFT_MERGE = LinkLine.LEFT_MERGE_PARENT | LinkLine.LEFT_MERGE_ANCESTOR; |
| b69ab31 | | | 317 | static RIGHT_MERGE = LinkLine.RIGHT_MERGE_PARENT | LinkLine.RIGHT_MERGE_ANCESTOR; |
| b69ab31 | | | 318 | static ANY_MERGE = LinkLine.LEFT_MERGE | LinkLine.RIGHT_MERGE; |
| b69ab31 | | | 319 | static ANY_FORK = LinkLine.LEFT_FORK | LinkLine.RIGHT_FORK; |
| b69ab31 | | | 320 | static ANY_FORK_OR_MERGE = LinkLine.ANY_MERGE | LinkLine.ANY_FORK; |
| b69ab31 | | | 321 | |
| b69ab31 | | | 322 | static from(value: number): LinkLine { |
| b69ab31 | | | 323 | return new LinkLine(value); |
| b69ab31 | | | 324 | } |
| b69ab31 | | | 325 | |
| b69ab31 | | | 326 | static empty(): LinkLine { |
| b69ab31 | | | 327 | return new LinkLine(0); |
| b69ab31 | | | 328 | } |
| b69ab31 | | | 329 | |
| b69ab31 | | | 330 | valueOf(): number { |
| b69ab31 | | | 331 | return this.value; |
| b69ab31 | | | 332 | } |
| b69ab31 | | | 333 | |
| b69ab31 | | | 334 | contains(value: number): boolean { |
| b69ab31 | | | 335 | return (this.value & value) === value; |
| b69ab31 | | | 336 | } |
| b69ab31 | | | 337 | |
| b69ab31 | | | 338 | intersects(value: number): boolean { |
| b69ab31 | | | 339 | return (this.value & value) !== 0; |
| b69ab31 | | | 340 | } |
| b69ab31 | | | 341 | |
| b69ab31 | | | 342 | or(value: number): LinkLine { |
| b69ab31 | | | 343 | return LinkLine.from(this.value | value); |
| b69ab31 | | | 344 | } |
| b69ab31 | | | 345 | } |
| b69ab31 | | | 346 | |
| b69ab31 | | | 347 | export enum NodeLine { |
| b69ab31 | | | 348 | Blank = 0, |
| b69ab31 | | | 349 | Ancestor = 1, |
| b69ab31 | | | 350 | Parent = 2, |
| b69ab31 | | | 351 | Node = 3, |
| b69ab31 | | | 352 | } |
| b69ab31 | | | 353 | |
| b69ab31 | | | 354 | export enum PadLine { |
| b69ab31 | | | 355 | Blank = 0, |
| b69ab31 | | | 356 | Ancestor = 1, |
| b69ab31 | | | 357 | Parent = 2, |
| b69ab31 | | | 358 | } |
| b69ab31 | | | 359 | |
| b69ab31 | | | 360 | type GraphRow = { |
| b69ab31 | | | 361 | hash: Hash; |
| b69ab31 | | | 362 | merge: boolean; |
| b69ab31 | | | 363 | /** The node ("o") columns for this row. */ |
| b69ab31 | | | 364 | nodeLine: Array<NodeLine>; |
| b69ab31 | | | 365 | /** The link columns for this row if necessary. Cannot be repeated. */ |
| b69ab31 | | | 366 | linkLine?: Array<LinkLine>; |
| b69ab31 | | | 367 | /** |
| b69ab31 | | | 368 | * The location of any terminators, if necessary. |
| b69ab31 | | | 369 | * Between postNode and ancestryLines. |
| b69ab31 | | | 370 | */ |
| b69ab31 | | | 371 | termLine?: Array<boolean>; |
| b69ab31 | | | 372 | /** |
| b69ab31 | | | 373 | * Lines to represent "ancestry" relationship. |
| b69ab31 | | | 374 | * "|" for direct parent, ":" for indirect ancestor. |
| b69ab31 | | | 375 | * Can be repeated. Can be skipped if there are no indirect ancestors. |
| b69ab31 | | | 376 | * Practically, CLI repeats this line. ISL "repeats" preNode and postNode lines. |
| b69ab31 | | | 377 | */ |
| b69ab31 | | | 378 | ancestryLine: Array<PadLine>; |
| b69ab31 | | | 379 | |
| b69ab31 | | | 380 | /** True if the node is a head (no children, uses a new column) */ |
| b69ab31 | | | 381 | isHead: boolean; |
| b69ab31 | | | 382 | /** True if the node is a root (no parents) */ |
| b69ab31 | | | 383 | isRoot: boolean; |
| b69ab31 | | | 384 | |
| b69ab31 | | | 385 | /** |
| b69ab31 | | | 386 | * Column that contains the "node" above the link line. |
| b69ab31 | | | 387 | * nodeLine[nodeColumn] should be NodeLine.Node. |
| b69ab31 | | | 388 | */ |
| b69ab31 | | | 389 | nodeColumn: number; |
| b69ab31 | | | 390 | |
| b69ab31 | | | 391 | /** |
| b69ab31 | | | 392 | * Parent columns reachable from "node" below the link line. |
| b69ab31 | | | 393 | */ |
| b69ab31 | | | 394 | parentColumns: number[]; |
| b69ab31 | | | 395 | |
| b69ab31 | | | 396 | /** |
| b69ab31 | | | 397 | * A subset of LinkLine that comes from "node". For example: |
| b69ab31 | | | 398 | * |
| b69ab31 | | | 399 | * │ o // node line |
| b69ab31 | | | 400 | * ├─╯ // link line |
| b69ab31 | | | 401 | * |
| b69ab31 | | | 402 | * The `fromNodeValue` LinkLine looks like: |
| b69ab31 | | | 403 | * |
| b69ab31 | | | 404 | * ╭─╯ |
| b69ab31 | | | 405 | * |
| b69ab31 | | | 406 | * Note "├" is changed to "╭". |
| b69ab31 | | | 407 | */ |
| b69ab31 | | | 408 | linkLineFromNode?: Array<LinkLine>; |
| b69ab31 | | | 409 | }; |
| b69ab31 | | | 410 | |
| b69ab31 | | | 411 | /** |
| b69ab31 | | | 412 | * Output row for a "commit". |
| b69ab31 | | | 413 | * |
| b69ab31 | | | 414 | * Example line types: |
| b69ab31 | | | 415 | * |
| b69ab31 | | | 416 | * ```plain |
| b69ab31 | | | 417 | * │ // preNodeLine (repeatable) |
| b69ab31 | | | 418 | * │ // preNodeLine |
| b69ab31 | | | 419 | * o F // nodeLine |
| b69ab31 | | | 420 | * │ very long message 0 // postNodeLine (repeatable) |
| b69ab31 | | | 421 | * │ very long message 0 // postNodeLine |
| b69ab31 | | | 422 | * ├─┬─╮ very long message 1 // linkLine |
| b69ab31 | | | 423 | * │ │ ~ very long message 2 // termLine |
| b69ab31 | | | 424 | * : │ very long message 3 // ancestryLine |
| b69ab31 | | | 425 | * │ │ very long message 4 // postAncestryLine (repeatable) |
| b69ab31 | | | 426 | * │ │ very long message 5 // postAncestryLine |
| b69ab31 | | | 427 | * ``` |
| b69ab31 | | | 428 | * |
| b69ab31 | | | 429 | * This is `GraphRow` with derived fields. |
| b69ab31 | | | 430 | */ |
| b69ab31 | | | 431 | export type ExtendedGraphRow = GraphRow & { |
| b69ab31 | | | 432 | /** If there are indirect ancestors, aka. the ancestryLine is interesting to render. */ |
| b69ab31 | | | 433 | hasIndirectAncestor: boolean; |
| b69ab31 | | | 434 | /** The columns before the node columns. Repeatable. */ |
| b69ab31 | | | 435 | preNodeLine: Array<PadLine>; |
| b69ab31 | | | 436 | /** The columns after node, before the term, link columns. Repeatable. */ |
| b69ab31 | | | 437 | postNodeLine: Array<PadLine>; |
| b69ab31 | | | 438 | /** The columns after ancestryLine. Repeatable. */ |
| b69ab31 | | | 439 | postAncestryLine: Array<PadLine>; |
| b69ab31 | | | 440 | /** Str to test equality. */ |
| b69ab31 | | | 441 | valueOf(): string; |
| b69ab31 | | | 442 | }; |
| b69ab31 | | | 443 | |
| b69ab31 | | | 444 | function nodeToPadLine(node: NodeLine, useBlank: boolean): PadLine { |
| b69ab31 | | | 445 | switch (node) { |
| b69ab31 | | | 446 | case NodeLine.Blank: |
| b69ab31 | | | 447 | return PadLine.Blank; |
| b69ab31 | | | 448 | case NodeLine.Ancestor: |
| b69ab31 | | | 449 | return PadLine.Ancestor; |
| b69ab31 | | | 450 | case NodeLine.Parent: |
| b69ab31 | | | 451 | return PadLine.Parent; |
| b69ab31 | | | 452 | case NodeLine.Node: |
| b69ab31 | | | 453 | return useBlank ? PadLine.Blank : PadLine.Parent; |
| b69ab31 | | | 454 | } |
| b69ab31 | | | 455 | } |
| b69ab31 | | | 456 | |
| b69ab31 | | | 457 | function extendGraphRow(row: GraphRow): ExtendedGraphRow { |
| b69ab31 | | | 458 | // Single string that includes all states of the row. |
| b69ab31 | | | 459 | // Useful to test equality. |
| b69ab31 | | | 460 | const rowStr = [ |
| b69ab31 | | | 461 | row.hash, |
| b69ab31 | | | 462 | row.nodeLine.join(''), |
| b69ab31 | | | 463 | row.linkLine?.map(l => l.value.toString(16)).join('') ?? '', |
| b69ab31 | | | 464 | row.termLine?.map(l => (l ? '1' : '0')).join('') ?? '', |
| b69ab31 | | | 465 | row.ancestryLine?.join('') ?? '', |
| b69ab31 | | | 466 | row.parentColumns.join(','), |
| b69ab31 | | | 467 | row.isHead ? 'h' : '', |
| b69ab31 | | | 468 | row.isRoot ? 'r' : '', |
| b69ab31 | | | 469 | ].join(';'); |
| b69ab31 | | | 470 | |
| b69ab31 | | | 471 | return { |
| b69ab31 | | | 472 | ...row, |
| b69ab31 | | | 473 | get hasIndirectAncestor() { |
| b69ab31 | | | 474 | return row.ancestryLine.some(line => line === PadLine.Ancestor); |
| b69ab31 | | | 475 | }, |
| b69ab31 | | | 476 | get preNodeLine() { |
| b69ab31 | | | 477 | return row.nodeLine.map(l => nodeToPadLine(l, row.isHead)); |
| b69ab31 | | | 478 | }, |
| b69ab31 | | | 479 | get postNodeLine() { |
| b69ab31 | | | 480 | return row.nodeLine.map(l => nodeToPadLine(l, row.isRoot)); |
| b69ab31 | | | 481 | }, |
| b69ab31 | | | 482 | get postAncestryLine() { |
| b69ab31 | | | 483 | return row.ancestryLine.map(l => (l === PadLine.Ancestor ? PadLine.Parent : l)); |
| b69ab31 | | | 484 | }, |
| b69ab31 | | | 485 | valueOf(): string { |
| b69ab31 | | | 486 | return rowStr; |
| b69ab31 | | | 487 | }, |
| b69ab31 | | | 488 | }; |
| b69ab31 | | | 489 | } |
| b69ab31 | | | 490 | |
| b69ab31 | | | 491 | type NextRowOptions = { |
| b69ab31 | | | 492 | /** |
| b69ab31 | | | 493 | * Ensure this node uses the last (right-most) column. |
| b69ab31 | | | 494 | * Only works for heads, i.e. nodes without children. |
| b69ab31 | | | 495 | */ |
| b69ab31 | | | 496 | forceLastColumn?: boolean; |
| b69ab31 | | | 497 | }; |
| b69ab31 | | | 498 | |
| b69ab31 | | | 499 | export class Renderer { |
| b69ab31 | | | 500 | private columns: Columns = new Columns(); |
| b69ab31 | | | 501 | |
| b69ab31 | | | 502 | /** |
| b69ab31 | | | 503 | * Reserve a column for the given hash. |
| b69ab31 | | | 504 | * This is usually used to indent draft commits by reserving |
| b69ab31 | | | 505 | * columns for public commits. |
| b69ab31 | | | 506 | */ |
| b69ab31 | | | 507 | reserve(hash: Hash) { |
| b69ab31 | | | 508 | if (this.columns.find(hash) == null) { |
| b69ab31 | | | 509 | const index = this.columns.firstEmpty(); |
| b69ab31 | | | 510 | const column = new Column({type: ColumnType.Reserved, hash}); |
| b69ab31 | | | 511 | if (index == null) { |
| b69ab31 | | | 512 | this.columns.inner.push(column); |
| b69ab31 | | | 513 | } else { |
| b69ab31 | | | 514 | this.columns.inner[index] = column; |
| b69ab31 | | | 515 | } |
| b69ab31 | | | 516 | } |
| b69ab31 | | | 517 | } |
| b69ab31 | | | 518 | |
| b69ab31 | | | 519 | /** |
| b69ab31 | | | 520 | * Render the next row. |
| b69ab31 | | | 521 | * Main logic of the renderer. |
| b69ab31 | | | 522 | */ |
| b69ab31 | | | 523 | nextRow(hash: Hash, parents: Array<Ancestor>, opts?: NextRowOptions): ExtendedGraphRow { |
| b69ab31 | | | 524 | const {forceLastColumn = false} = opts ?? {}; |
| b69ab31 | | | 525 | |
| b69ab31 | | | 526 | // Find a column for this node. |
| b69ab31 | | | 527 | const existingColumn = this.columns.find(hash); |
| b69ab31 | | | 528 | let column: number; |
| b69ab31 | | | 529 | if (forceLastColumn) { |
| b69ab31 | | | 530 | assert( |
| b69ab31 | | | 531 | existingColumn == null, |
| b69ab31 | | | 532 | 'requireLastColumn should only apply to heads (ex. "You are here")', |
| b69ab31 | | | 533 | ); |
| b69ab31 | | | 534 | column = this.columns.newEmpty(); |
| b69ab31 | | | 535 | } else { |
| b69ab31 | | | 536 | column = existingColumn ?? this.columns.firstEmpty() ?? this.columns.newEmpty(); |
| b69ab31 | | | 537 | } |
| b69ab31 | | | 538 | const isHead = |
| b69ab31 | | | 539 | existingColumn == null || this.columns.inner.at(existingColumn)?.type === ColumnType.Reserved; |
| b69ab31 | | | 540 | const isRoot = parents.length === 0; |
| b69ab31 | | | 541 | |
| b69ab31 | | | 542 | this.columns.inner[column] = Column.empty(); |
| b69ab31 | | | 543 | |
| b69ab31 | | | 544 | // This row is for a merge if there are multiple parents. |
| b69ab31 | | | 545 | const merge = parents.length > 1; |
| b69ab31 | | | 546 | |
| b69ab31 | | | 547 | // Build the initial node line. |
| b69ab31 | | | 548 | const nodeLine: NodeLine[] = this.columns.inner.map(c => c.toNodeLine()); |
| b69ab31 | | | 549 | nodeLine[column] = NodeLine.Node; |
| b69ab31 | | | 550 | |
| b69ab31 | | | 551 | // Build the initial link line. |
| b69ab31 | | | 552 | const linkLine: LinkLine[] = this.columns.inner.map(c => c.toLinkLine()); |
| b69ab31 | | | 553 | const linkLineFromNode: LinkLine[] = this.columns.inner.map(_c => LinkLine.empty()); |
| b69ab31 | | | 554 | linkLineFromNode[column] = linkLine[column]; |
| b69ab31 | | | 555 | let needLinkLine = false; |
| b69ab31 | | | 556 | |
| b69ab31 | | | 557 | // Update linkLine[i] and linkLineFromNode[i] to include `bits`. |
| b69ab31 | | | 558 | const linkBoth = (i: number, bits: number) => { |
| b69ab31 | | | 559 | if (bits < 0) { |
| b69ab31 | | | 560 | linkLine[i] = LinkLine.from(bits); |
| b69ab31 | | | 561 | linkLineFromNode[i] = LinkLine.from(bits); |
| b69ab31 | | | 562 | } else { |
| b69ab31 | | | 563 | linkLine[i] = linkLine[i].or(bits); |
| b69ab31 | | | 564 | linkLineFromNode[i] = linkLineFromNode[i].or(bits); |
| b69ab31 | | | 565 | } |
| b69ab31 | | | 566 | }; |
| b69ab31 | | | 567 | |
| b69ab31 | | | 568 | // Build the initial term line. |
| b69ab31 | | | 569 | const termLine: boolean[] = this.columns.inner.map(_c => false); |
| b69ab31 | | | 570 | let needTermLine = false; |
| b69ab31 | | | 571 | |
| b69ab31 | | | 572 | // Build the initial ancestry line. |
| b69ab31 | | | 573 | const ancestryLine: PadLine[] = this.columns.inner.map(c => c.toPadLine()); |
| b69ab31 | | | 574 | |
| b69ab31 | | | 575 | // Assign each parent to a column. |
| b69ab31 | | | 576 | const parentColumns = new Map<number, Ancestor>(); |
| b69ab31 | | | 577 | for (const p of parents) { |
| b69ab31 | | | 578 | // Check if the parent already has a column. |
| b69ab31 | | | 579 | const parentId = p.id(); |
| b69ab31 | | | 580 | if (parentId != null) { |
| b69ab31 | | | 581 | const index = this.columns.find(parentId); |
| b69ab31 | | | 582 | if (index != null) { |
| b69ab31 | | | 583 | this.columns.merge(index, p.toColumn()); |
| b69ab31 | | | 584 | parentColumns.set(index, p); |
| b69ab31 | | | 585 | continue; |
| b69ab31 | | | 586 | } |
| b69ab31 | | | 587 | } |
| b69ab31 | | | 588 | |
| b69ab31 | | | 589 | // Assign the parent to an empty column, preferring the column |
| b69ab31 | | | 590 | // the current node is going in, to maintain linearity. |
| b69ab31 | | | 591 | const index = this.columns.findEmpty(column); |
| b69ab31 | | | 592 | if (index != null) { |
| b69ab31 | | | 593 | this.columns.merge(index, p.toColumn()); |
| b69ab31 | | | 594 | parentColumns.set(index, p); |
| b69ab31 | | | 595 | continue; |
| b69ab31 | | | 596 | } |
| b69ab31 | | | 597 | |
| b69ab31 | | | 598 | // There are no empty columns left. Make a new column. |
| b69ab31 | | | 599 | parentColumns.set(this.columns.inner.length, p); |
| b69ab31 | | | 600 | nodeLine.push(NodeLine.Blank); |
| b69ab31 | | | 601 | ancestryLine.push(PadLine.Blank); |
| b69ab31 | | | 602 | linkLine.push(LinkLine.empty()); |
| b69ab31 | | | 603 | linkLineFromNode.push(LinkLine.empty()); |
| b69ab31 | | | 604 | termLine.push(false); |
| b69ab31 | | | 605 | this.columns.inner.push(p.toColumn()); |
| b69ab31 | | | 606 | } |
| b69ab31 | | | 607 | |
| b69ab31 | | | 608 | // Mark parent columns with anonymous parents as terminating. |
| b69ab31 | | | 609 | for (const [i, p] of parentColumns.entries()) { |
| b69ab31 | | | 610 | if (p.id() == null) { |
| b69ab31 | | | 611 | termLine[i] = true; |
| b69ab31 | | | 612 | needTermLine = true; |
| b69ab31 | | | 613 | } |
| b69ab31 | | | 614 | } |
| b69ab31 | | | 615 | |
| b69ab31 | | | 616 | // Check if we can move the parent to the current column. |
| b69ab31 | | | 617 | // |
| b69ab31 | | | 618 | // Before After |
| b69ab31 | | | 619 | // ├─╮ ├─╮ |
| b69ab31 | | | 620 | // │ o C │ o C |
| b69ab31 | | | 621 | // o ╷ B o ╷ B |
| b69ab31 | | | 622 | // ╰─╮ ├─╯ |
| b69ab31 | | | 623 | // o A o A |
| b69ab31 | | | 624 | // |
| b69ab31 | | | 625 | // o J o J |
| b69ab31 | | | 626 | // ├─┬─╮ ├─┬─╮ |
| b69ab31 | | | 627 | // │ │ o I │ │ o I |
| b69ab31 | | | 628 | // │ o │ H │ o │ H |
| b69ab31 | | | 629 | // ╭─┼─┬─┬─╮ ╭─┼─┬─┬─╮ |
| b69ab31 | | | 630 | // │ │ │ │ o G │ │ │ │ o G |
| b69ab31 | | | 631 | // │ │ │ o │ E │ │ │ o │ E |
| b69ab31 | | | 632 | // │ │ │ ╰─┤ │ │ │ ├─╯ |
| b69ab31 | | | 633 | // │ │ o │ D │ │ o │ D |
| b69ab31 | | | 634 | // │ │ ├───╮ │ │ ├─╮ |
| b69ab31 | | | 635 | // │ o │ │ C │ o │ │ C |
| b69ab31 | | | 636 | // │ ╰─────┤ │ ├───╯ |
| b69ab31 | | | 637 | // o │ │ F o │ │ F |
| b69ab31 | | | 638 | // ╰───────┤ ├─╯ │ |
| b69ab31 | | | 639 | // │ o B o │ B |
| b69ab31 | | | 640 | // ├───╯ ├───╯ |
| b69ab31 | | | 641 | // o A o A |
| b69ab31 | | | 642 | if (parents.length === 1) { |
| b69ab31 | | | 643 | const [[parentColumn, parentAncestor]] = parentColumns.entries(); |
| b69ab31 | | | 644 | if (parentColumn != null && parentColumn > column) { |
| b69ab31 | | | 645 | // This node has a single parent which was already |
| b69ab31 | | | 646 | // assigned to a column to the right of this one. |
| b69ab31 | | | 647 | // Move the parent to this column. |
| b69ab31 | | | 648 | this.columns.swap(column, parentColumn); |
| b69ab31 | | | 649 | parentColumns.delete(parentColumn); |
| b69ab31 | | | 650 | parentColumns.set(column, parentAncestor); |
| b69ab31 | | | 651 | // Generate a line from this column to the old |
| b69ab31 | | | 652 | // parent column. We need to continue with the style |
| b69ab31 | | | 653 | // that was being used for the parent column. |
| b69ab31 | | | 654 | // |
| b69ab31 | | | 655 | // old parent |
| b69ab31 | | | 656 | // o v |
| b69ab31 | | | 657 | // ╭────╯ |
| b69ab31 | | | 658 | // ^ |
| b69ab31 | | | 659 | // new parent (moved here, nodeColumn) |
| b69ab31 | | | 660 | const wasDirect = linkLine.at(parentColumn)?.contains(LinkLine.VERT_PARENT); |
| b69ab31 | | | 661 | linkLine[column] = linkLine[column].or( |
| b69ab31 | | | 662 | wasDirect ? LinkLine.RIGHT_FORK_PARENT : LinkLine.RIGHT_FORK_ANCESTOR, |
| b69ab31 | | | 663 | ); |
| b69ab31 | | | 664 | for (let i = column + 1; i < parentColumn; ++i) { |
| b69ab31 | | | 665 | linkLine[i] = linkLine[i].or(wasDirect ? LinkLine.HORIZ_PARENT : LinkLine.HORIZ_ANCESTOR); |
| b69ab31 | | | 666 | } |
| b69ab31 | | | 667 | linkLine[parentColumn] = LinkLine.from( |
| b69ab31 | | | 668 | wasDirect ? LinkLine.LEFT_MERGE_PARENT : LinkLine.LEFT_MERGE_ANCESTOR, |
| b69ab31 | | | 669 | ); |
| b69ab31 | | | 670 | needLinkLine = true; |
| b69ab31 | | | 671 | // The ancestry line for the old parent column is now blank. |
| b69ab31 | | | 672 | ancestryLine[parentColumn] = PadLine.Blank; |
| b69ab31 | | | 673 | } |
| b69ab31 | | | 674 | } |
| b69ab31 | | | 675 | |
| b69ab31 | | | 676 | // Connect the node column to all the parent columns. |
| b69ab31 | | | 677 | const bounds = AncestorColumnBounds.new([...parentColumns.entries()], column); |
| b69ab31 | | | 678 | if (bounds != null) { |
| b69ab31 | | | 679 | // If the parents extend beyond the columns adjacent to the node, draw a horizontal |
| b69ab31 | | | 680 | // ancestor line between the two outermost ancestors. |
| b69ab31 | | | 681 | for (const i of bounds.range()) { |
| b69ab31 | | | 682 | linkBoth(i, bounds.horizontalLine(i).value); |
| b69ab31 | | | 683 | needLinkLine = true; |
| b69ab31 | | | 684 | } |
| b69ab31 | | | 685 | // If there is a parent or ancestor to the right of the node |
| b69ab31 | | | 686 | // column, the node merges from the right. |
| b69ab31 | | | 687 | if (bounds.maxParent > column) { |
| b69ab31 | | | 688 | linkBoth(column, LinkLine.RIGHT_MERGE_PARENT); |
| b69ab31 | | | 689 | needLinkLine = true; |
| b69ab31 | | | 690 | } else if (bounds.maxAncestor > column) { |
| b69ab31 | | | 691 | linkBoth(column, LinkLine.RIGHT_MERGE_ANCESTOR); |
| b69ab31 | | | 692 | needLinkLine = true; |
| b69ab31 | | | 693 | } |
| b69ab31 | | | 694 | // If there is a parent or ancestor to the left of the node column, the node merges from the left. |
| b69ab31 | | | 695 | if (bounds.minParent < column) { |
| b69ab31 | | | 696 | linkBoth(column, LinkLine.LEFT_MERGE_PARENT); |
| b69ab31 | | | 697 | needLinkLine = true; |
| b69ab31 | | | 698 | } else if (bounds.minAncestor < column) { |
| b69ab31 | | | 699 | linkBoth(column, LinkLine.LEFT_MERGE_ANCESTOR); |
| b69ab31 | | | 700 | needLinkLine = true; |
| b69ab31 | | | 701 | } |
| b69ab31 | | | 702 | // Each parent or ancestor forks towards the node column. |
| b69ab31 | | | 703 | for (const [i, p] of parentColumns.entries()) { |
| b69ab31 | | | 704 | ancestryLine[i] = this.columns.inner[i].toPadLine(); |
| b69ab31 | | | 705 | let orValue = 0; |
| b69ab31 | | | 706 | if (i < column) { |
| b69ab31 | | | 707 | orValue = p.toLinkLine( |
| b69ab31 | | | 708 | LinkLine.from(LinkLine.RIGHT_FORK_PARENT), |
| b69ab31 | | | 709 | LinkLine.from(LinkLine.RIGHT_FORK_ANCESTOR), |
| b69ab31 | | | 710 | ).value; |
| b69ab31 | | | 711 | } else if (i === column) { |
| b69ab31 | | | 712 | orValue = |
| b69ab31 | | | 713 | LinkLine.CHILD | |
| b69ab31 | | | 714 | p.toLinkLine(LinkLine.from(LinkLine.VERT_PARENT), LinkLine.from(LinkLine.VERT_ANCESTOR)) |
| b69ab31 | | | 715 | .value; |
| b69ab31 | | | 716 | } else { |
| b69ab31 | | | 717 | orValue = p.toLinkLine( |
| b69ab31 | | | 718 | LinkLine.from(LinkLine.LEFT_FORK_PARENT), |
| b69ab31 | | | 719 | LinkLine.from(LinkLine.LEFT_FORK_ANCESTOR), |
| b69ab31 | | | 720 | ).value; |
| b69ab31 | | | 721 | } |
| b69ab31 | | | 722 | linkBoth(i, orValue); |
| b69ab31 | | | 723 | } |
| b69ab31 | | | 724 | } |
| b69ab31 | | | 725 | |
| b69ab31 | | | 726 | // Only show ":" once per branch. |
| b69ab31 | | | 727 | this.columns.convertAncestorToParent(); |
| b69ab31 | | | 728 | |
| b69ab31 | | | 729 | // Now that we have assigned all the columns, reset their state. |
| b69ab31 | | | 730 | this.columns.reset(); |
| b69ab31 | | | 731 | |
| b69ab31 | | | 732 | // Filter out the link line or term line if they are not needed. |
| b69ab31 | | | 733 | const optionalLinkLine = needLinkLine ? linkLine : undefined; |
| b69ab31 | | | 734 | const optionalTermLine = needTermLine ? termLine : undefined; |
| b69ab31 | | | 735 | |
| b69ab31 | | | 736 | const row: GraphRow = { |
| b69ab31 | | | 737 | hash, |
| b69ab31 | | | 738 | merge, |
| b69ab31 | | | 739 | nodeLine, |
| b69ab31 | | | 740 | linkLine: optionalLinkLine, |
| b69ab31 | | | 741 | termLine: optionalTermLine, |
| b69ab31 | | | 742 | ancestryLine, |
| b69ab31 | | | 743 | isHead, |
| b69ab31 | | | 744 | isRoot, |
| b69ab31 | | | 745 | nodeColumn: column, |
| b69ab31 | | | 746 | parentColumns: [...parentColumns.keys()].sort((a, b) => a - b), |
| b69ab31 | | | 747 | linkLineFromNode: needLinkLine ? linkLineFromNode : undefined, |
| b69ab31 | | | 748 | }; |
| b69ab31 | | | 749 | |
| b69ab31 | | | 750 | return extendGraphRow(row); |
| b69ab31 | | | 751 | } |
| b69ab31 | | | 752 | } |