/**
* egjs-grid
* Copyright (c) 2021-present NAVER Corp.
* MIT license
*/
import Grid from "../Grid";
import { MOUNT_STATE, PROPERTY_TYPE } from "../consts";
import { GridOptions, Properties, GridOutlines } from "../types";
import { getRangeCost, GetterSetter, isObject } from "../utils";
import { find_path } from "./lib/dijkstra";
import { GridItem } from "../GridItem";
interface Link {
path: number[];
cost: number;
length: number;
currentNode: number;
isOver?: boolean;
}
function splitItems(items: GridItem[], path: string[]) {
const length = path.length;
const groups: GridItem[][] = [];
for (let i = 0; i < length - 1; ++i) {
const path1 = parseInt(path[i], 10);
const path2 = parseInt(path[i + 1], 10);
groups.push(items.slice(path1, path2));
}
return groups;
}
function getExpectedColumnSize(item: GridItem, rowSize: number) {
const inlineSize = item.orgInlineSize;
const contentSize = item.orgContentSize;
if (!inlineSize || !contentSize) {
return rowSize;
}
const inlineOffset = parseFloat(item.gridData.inlineOffset) || 0;
const contentOffset = parseFloat(item.gridData.contentOffset) || 0;
const ratio = contentSize <= contentOffset ? 1 : (inlineSize - inlineOffset) / (contentSize - contentOffset);
return ratio * (rowSize - contentOffset) + inlineOffset;
}
/**
* @typedef
* @memberof Grid.JustifiedGrid
* @extends Grid.GridOptions
*/
export interface JustifiedGridOptions extends GridOptions {
/**
* The minimum and maximum number of items per line.
* <ko> 한 줄에 들어가는 아이템의 최소, 최대 개수.</ko>
* @default [1, 8]
*/
columnRange?: number | number[];
/**
* The minimum and maximum number of rows in a group, 0 is not set.
* <ko> 한 그룹에 들어가는 행의 최소, 최대 개수, 0은 미설정이다.</ko>
* @default 0
*/
rowRange?: number | number[];
/**
* The minimum and maximum size by which the item is adjusted. If it is not calculated, it may deviate from the minimum and maximum sizes.
* <ko>아이템이 조정되는 최소, 최대 사이즈. 계산이 되지 않는 경우 최소, 최대 사이즈를 벗어날 수 있다.</ko>
* @default [0, Infinity]
*/
sizeRange?: number | number[];
/**
* Maximum number of rows to be counted for container size. You can hide it on the screen by setting overflow: hidden. -1 is not set.
* <ko>컨테이너 크기에 계산될 최대 row 개수. overflow: hidden을 설정하면 화면에 가릴 수 있다. -1은 미설정이다.</ko>
* @default -1
*/
displayedRow?: number;
/**
* Whether to crop when the row size is out of sizeRange. If set to true, this ratio can be broken.
* <ko>row사이즈가 sizeRange에 벗어나면 크롭할지 여부. true로 설정하면 비율이 깨질 수 있다.</ko>
* @default false
*/
isCroppedSize?: boolean;
}
/**
* 'justified' is a printing term with the meaning that 'it fits in one row wide'. JustifiedGrid is a grid that the item is filled up on the basis of a line given a size.
* If 'data-grid-inline-offset' or 'data-grid-content-offset' are set for item element, the ratio is maintained except for the offset value.
* If 'data-grid-maintained-target' is set for an element whose ratio is to be maintained, the item is rendered while maintaining the ratio of the element.
* @ko 'justified'는 '1행의 너비에 맞게 꼭 들어찬'이라는 의미를 가진 인쇄 용어다. JustifiedGrid는 용어의 의미대로 너비가 주어진 사이즈를 기준으로 아이템가 가득 차도록 배치하는 Grid다.
* 아이템 엘리먼트에 'data-grid-inline-offset' 또는 'data-grid-content-offset'를 설정하면 offset 값을 제외하고 비율을 유지한다.
* 비율을 유지하고 싶은 엘리먼트에 'data-grid-maintained-target'을 설정한다면 해당 엘리먼트의 비율을 유지하면서 아이템이 렌더링이 된다.
* @memberof Grid
* @param {HTMLElement | string} container - A base element for a module <ko>모듈을 적용할 기준 엘리먼트</ko>
* @param {Grid.JustifiedGrid.JustifiedGridOptions} options - The option object of the JustifiedGrid module <ko>JustifiedGrid 모듈의 옵션 객체</ko>
*/
@GetterSetter
export class JustifiedGrid extends Grid<JustifiedGridOptions> {
public static propertyTypes = {
...Grid.propertyTypes,
columnRange: PROPERTY_TYPE.RENDER_PROPERTY,
rowRange: PROPERTY_TYPE.RENDER_PROPERTY,
sizeRange: PROPERTY_TYPE.RENDER_PROPERTY,
isCroppedSize: PROPERTY_TYPE.RENDER_PROPERTY,
displayedRow: PROPERTY_TYPE.RENDER_PROPERTY,
};
public static defaultOptions: Required<JustifiedGridOptions> = {
...Grid.defaultOptions,
columnRange: [1, 8],
rowRange: 0,
sizeRange: [0, Infinity],
displayedRow: -1,
isCroppedSize: false,
};
public applyGrid(items: GridItem[], direction: "start" | "end", outline: number[]): GridOutlines {
const {
attributePrefix,
horizontal,
} = this.options;
items.forEach((item) => {
if (!item.isUpdating) {
return;
}
const element = item.element;
const attributes = item.attributes;
const gridData = item.gridData;
let inlineOffset = parseFloat(attributes.inlineOffset) || gridData.inlineOffset || 0;
let contentOffset = parseFloat(attributes.contentOffset) || gridData.contentOffset | 0;
if (
element && !("inlineOffset" in attributes) && !("contentOffset" in attributes)
&& item.mountState === MOUNT_STATE.MOUNTED
) {
const maintainedTarget = element.querySelector(`[${attributePrefix}maintained-target]`);
if (maintainedTarget) {
const widthOffset = element.offsetWidth - element.clientWidth
+ element.scrollWidth - maintainedTarget.clientWidth;
const heightOffset = element.offsetHeight - element.clientHeight
+ element.scrollHeight - maintainedTarget.clientHeight;
if (horizontal) {
inlineOffset = heightOffset;
contentOffset = widthOffset;
} else {
inlineOffset = widthOffset;
contentOffset = heightOffset;
}
}
}
gridData.inlineOffset = inlineOffset;
gridData.contentOffset = contentOffset;
});
const rowRange = this.options.rowRange;
let path: string[] = [];
if (items.length) {
path = rowRange ? this._getRowPath(items) : this._getPath(items);
}
return this._setStyle(items, path, outline, direction === "end");
}
private _getRowPath(items: GridItem[]) {
const columnRange = this._getColumnRange();
const rowRange = this._getRowRange();
const pathLink = this._getRowLink(items, {
path: [0],
cost: 0,
length: 0,
currentNode: 0,
}, columnRange, rowRange);
return pathLink?.path.map((node) => `${node}`) ?? [];
}
private _getRowLink(
items: GridItem[],
currentLink: Link,
columnRange: number[],
rowRange: number[]
): Link {
const [minColumn] = columnRange;
const [minRow, maxRow] = rowRange;
const lastNode = items.length;
const {
path,
length: pathLength,
cost,
currentNode,
} = currentLink;
// not reached lastNode but path is exceed or the number of remaining nodes is less than minColumn.
if (currentNode < lastNode && (maxRow <= pathLength || currentNode + minColumn > lastNode)) {
const rangeCost = getRangeCost(lastNode - currentNode, columnRange);
const lastCost = rangeCost * Math.abs(this._getCost(items, currentNode, lastNode));
return {
...currentLink,
length: pathLength + 1,
path: [...path, lastNode],
currentNode: lastNode,
cost: cost + lastCost,
isOver: true,
};
} else if (currentNode >= lastNode) {
return {
...currentLink,
currentNode: lastNode,
isOver: minRow > pathLength || maxRow < pathLength,
};
} else {
return this._searchRowLink(items, currentLink, lastNode, columnRange, rowRange);
}
}
private _searchRowLink(
items: GridItem[],
currentLink: Link,
lastNode: number,
columnRange: number[],
rowRange: number[]
) {
const [minColumn, maxColumn] = columnRange;
const {
currentNode,
path,
length: pathLength,
cost,
} = currentLink;
const length = Math.min(lastNode, currentNode + maxColumn);
const links: Link[] = [];
for (let nextNode = currentNode + minColumn; nextNode <= length; ++nextNode) {
if (nextNode === currentNode) {
continue;
}
const nextCost = Math.abs(this._getCost(items, currentNode, nextNode));
const nextLink = this._getRowLink(items, {
path: [...path, nextNode],
length: pathLength + 1,
cost: cost + nextCost,
currentNode: nextNode,
}, columnRange, rowRange);
if (nextLink) {
links.push(nextLink);
}
}
links.sort((a, b) => {
const aIsOver = a.isOver;
const bIsOver = b.isOver;
if (aIsOver !== bIsOver) {
// If it is over, the cost is high.
return aIsOver ? 1 : -1;
}
const aRangeCost = getRangeCost(a.length, rowRange);
const bRangeCost = getRangeCost(b.length, rowRange);
return aRangeCost - bRangeCost || a.cost - b.cost;
});
// It returns the lowest cost link.
return links[0];
}
private _getExpectedRowSize(items: GridItem[]) {
const {
gap,
} = this.options;
let containerInlineSize = this.getContainerInlineSize()! - gap * (items.length - 1);
let ratioSum = 0;
let inlineSum = 0;
items.forEach((item) => {
const inlineSize = item.orgInlineSize;
const contentSize = item.orgContentSize;
if (!inlineSize || !contentSize) {
ratioSum += 1;
return;
}
// sum((expect - offset) * ratio) = container inline size
const inlineOffset = parseFloat(item.gridData.inlineOffset) || 0;
const contentOffset = parseFloat(item.gridData.contentOffset) || 0;
const maintainedRatio = contentSize <= contentOffset ? 1
: (inlineSize - inlineOffset) / (contentSize - contentOffset);
ratioSum += maintainedRatio;
inlineSum += contentOffset * maintainedRatio;
containerInlineSize -= inlineOffset;
});
return ratioSum ? (containerInlineSize + inlineSum) / ratioSum : 0;
}
private _getExpectedInlineSize(items: GridItem[], rowSize: number) {
const {
gap,
} = this.options;
const size = items.reduce((sum, item) => {
return sum + getExpectedColumnSize(item, rowSize);
}, 0);
return size ? size + gap * (items.length - 1) : 0;
}
private _getCost(
items: GridItem[],
i: number,
j: number,
) {
const lineItems = items.slice(i, j);
const rowSize = this._getExpectedRowSize(lineItems);
const [minSize, maxSize] = this._getSizeRange();
if (this.isCroppedSize) {
if (minSize <= rowSize && rowSize <= maxSize) {
return 0;
}
const expectedInlineSize = this._getExpectedInlineSize(
lineItems,
rowSize < minSize ? minSize : maxSize,
);
return Math.pow(expectedInlineSize - this.getContainerInlineSize(), 2);
}
if (isFinite(maxSize)) {
// if this size is not in range, the cost increases sharply.
if (rowSize < minSize) {
return Math.pow(rowSize - minSize, 2) + Math.pow(maxSize, 2);
} else if (rowSize > maxSize) {
return Math.pow(rowSize - maxSize, 2) + Math.pow(maxSize, 2);
}
} else if (rowSize < minSize) {
return Math.max(Math.pow(minSize, 2), Math.pow(rowSize, 2)) + Math.pow(maxSize, 2);
}
// if this size in range, the cost is row
return rowSize - minSize;
}
private _getPath(items: GridItem[]) {
const lastNode = items.length;
const columnRangeOption = this.options.columnRange;
const [minColumn, maxColumn]: number[] = isObject(columnRangeOption)
? columnRangeOption
: [columnRangeOption, columnRangeOption];
const graph = (nodeKey: string) => {
const results: { [key: string]: number } = {};
const currentNode = parseInt(nodeKey, 10);
for (let nextNode = Math.min(currentNode + minColumn, lastNode); nextNode <= lastNode; ++nextNode) {
if (nextNode - currentNode > maxColumn) {
break;
}
let cost = this._getCost(
items,
currentNode,
nextNode,
);
if (cost < 0 && nextNode === lastNode) {
cost = 0;
}
results[`${nextNode}`] = Math.pow(cost, 2);
}
return results;
};
// shortest path for items' total height.
return find_path(graph, "0", `${lastNode}`);
}
private _setStyle(
items: GridItem[],
path: string[],
outline: number[] = [],
isEndDirection: boolean,
) {
const {
gap,
isCroppedSize,
displayedRow,
} = this.options;
const sizeRange = this._getSizeRange();
const startPoint = outline[0] || 0;
const containerInlineSize = this.getContainerInlineSize();
const groups = splitItems(items, path);
let contentPos = startPoint;
let displayedSize = 0;
groups.forEach((groupItems, rowIndex) => {
const length = groupItems.length;
let rowSize = this._getExpectedRowSize(groupItems);
if (isCroppedSize) {
rowSize = Math.max(sizeRange[0], Math.min(rowSize, sizeRange[1]));
}
const expectedInlineSize = this._getExpectedInlineSize(groupItems, rowSize);
const allGap = gap * (length - 1);
const scale = (containerInlineSize - allGap) / (expectedInlineSize - allGap);
groupItems.forEach((item, i) => {
let columnSize = getExpectedColumnSize(item, rowSize);
const prevItem = groupItems[i - 1];
const inlinePos = prevItem
? prevItem.cssInlinePos! + prevItem.cssInlineSize! + gap
: 0;
if (isCroppedSize) {
columnSize *= scale;
}
item.setCSSGridRect({
inlinePos,
contentPos,
inlineSize: columnSize,
contentSize: rowSize,
});
});
contentPos += gap + rowSize;
if (displayedRow < 0 || rowIndex < displayedRow) {
displayedSize = contentPos;
}
});
if (isEndDirection) {
// previous group's end outline is current group's start outline
return {
start: [startPoint],
end: [displayedSize],
};
}
// always start is lower than end.
// contentPos is endPoinnt
const height = contentPos - startPoint;
items.forEach((item) => {
item.cssContentPos! -= height;
});
return {
start: [startPoint - height],
end: [startPoint], // endPoint - height = startPoint
};
}
public getComputedOutlineLength() {
return 1;
}
public getComputedOutlineSize() {
return this.getContainerInlineSize();
}
private _getRowRange() {
const rowRange = this.rowRange;
return isObject(rowRange) ? rowRange : [rowRange, rowRange];
}
private _getColumnRange() {
const columnRange = this.columnRange;
return isObject(columnRange) ? columnRange : [columnRange, columnRange];
}
private _getSizeRange() {
const sizeRange = this.sizeRange;
return isObject(sizeRange) ? sizeRange : [sizeRange, sizeRange];
}
}
export interface JustifiedGrid extends Properties<typeof JustifiedGrid> {
}
/**
* The minimum and maximum number of items per line.
* @ko 한 줄에 들어가는 아이템의 최소, 최대 개수.
* @name Grid.JustifiedGrid#columnRange
* @type {$ts:Grid.JustifiedGrid.JustifiedGridOptions["columnRange"]}
* @default [1, 8]
* @example
* ```js
* import { JustifiedGrid } from "@egjs/grid";
*
* const grid = new JustifiedGrid(container, {
* columnRange: [1, 8],
* });
*
* grid.columnRange = [3, 6];
* ```
*/
/**
* The minimum and maximum number of rows in a group, 0 is not set.
* @ko 한 그룹에 들어가는 행의 최소, 최대 개수, 0은 미설정이다.
* @name Grid.JustifiedGrid#rowRange
* @type {$ts:Grid.JustifiedGrid.JustifiedGridOptions["rowRange"]}
* @default 0
* @example
* ```js
* import { JustifiedGrid } from "@egjs/grid";
*
* const grid = new JustifiedGrid(container, {
* rowRange: 0,
* });
*
* grid.rowRange = [3, 4];
* ```
*/
/**
* The minimum and maximum size by which the item is adjusted. If it is not calculated, it may deviate from the minimum and maximum sizes.
* @ko 아이템이 조정되는 최소, 최대 사이즈. 계산이 되지 않는 경우 최소, 최대 사이즈를 벗어날 수 있다.
* @name Grid.JustifiedGrid#sizeRange
* @type {$ts:Grid.JustifiedGrid.JustifiedGridOptions["sizeRange"]}
* @default [0, Infinity]
* @example
* ```js
* import { JustifiedGrid } from "@egjs/grid";
*
* const grid = new JustifiedGrid(container, {
* sizeRange: [0, Infinity],
* });
*
* grid.sizeRange = [200, 800];
* ```
*/
/**
* Maximum number of rows to be counted for container size. You can hide it on the screen by setting overflow: hidden. -1 is not set.
* @ko - 컨테이너 크기에 계산될 최대 row 개수. overflow: hidden을 설정하면 화면에 가릴 수 있다. -1은 미설정이다.
* @name Grid.JustifiedGrid#displayedRow
* @type {$ts:Grid.JustifiedGrid.JustifiedGridOptions["displayedRow"]}
* @default -1
* @example
* ```js
* import { JustifiedGrid } from "@egjs/grid";
*
* const grid = new JustifiedGrid(container, {
* displayedRow: -1,
* });
*
* grid.displayedRow = 3;
* ```
*/
/**
* Whether to crop when the row size is out of sizeRange. If set to true, this ratio can be broken.
* @ko - row 사이즈가 sizeRange에 벗어나면 크롭할지 여부. true로 설정하면 비율이 깨질 수 있다.
* @name Grid.JustifiedGrid#isCroppedSize
* @type {$ts:Grid.JustifiedGrid.JustifiedGridOptions["isCroppedSize"]}
* @default false
* @example
* ```js
* import { JustifiedGrid } from "@egjs/grid";
*
* const grid = new JustifiedGrid(container, {
* sizeRange: [200, 250],
* isCroppedSize: false,
* });
*
* grid.isCroppedSize = true;
* ```
*/