pivotgrid-js 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,18 +1,18 @@
1
- var PivotGridLib=(()=>{class r{static ROW_HEIGHT=24;static HEADER_HEIGHT=32;static COL_HEADER_W=200;static COL_W=150;static INDENT=16;static BUFFER=5;constructor({container:e,result:t,rows:s,columns:l,measure:o,fieldDefs:i={},labels:n={}}){this.container=e,this.rows=s,this.columns=l,this.measure=o,this.fieldDefs=i,this._labels=n,this._measureKey=o+"_sum",this._colHeaderW=r.COL_HEADER_W,this._hideSubtotals=!1,this.collapsed=new Set,this.collapsedCols=new Set,this.rowPool=[],this.rendered=new Map,this._applyResult(t),this._mount(),this._renderVisible(),this._bindScroll()}_applyResult(e){this.cells=e.cells,this.colTree=e.colTree,this.colKeys=e.colKeys,this.tree=e.tree,this.grandTotal=e.grandTotal,e.measureKey&&(this._measureKey=e.measureKey),this._buildFlatCols(),this._buildFlatRows()}_buildFlatCols(){if(!this.colTree||!this.colTree.length){this.flatCols=[];return}const e=[],t=this.columns&&this.columns.length>1,s=l=>{for(const o of l)o.children?this.collapsedCols.has(o.code)?e.push({code:o.code,label:o.value,isSubtotal:!0,collapsed:!0}):(s(o.children),t&&!this._hideSubtotals&&e.push({code:o.code,label:"\u2211",isSubtotal:!0,collapsed:!1})):e.push({code:o.code,label:o.value,isSubtotal:!1})};s(this.colTree),this.flatCols=e}_getGroupSpan(e){if(!e.children||this.collapsedCols.has(e.code))return 1;let s=this.columns&&this.columns.length>1&&!this._hideSubtotals?1:0;for(const l of e.children)s+=this._getGroupSpan(l);return s}_colTreeDepth(){if(!this.colTree||!this.colTree.length)return 1;const e=t=>{let s=0;for(const l of t)l.children&&!this.collapsedCols.has(l.code)&&(s=Math.max(s,1+e(l.children)));return s};return 1+e(this.colTree)}_buildFlatRows(){this.flatRows=[];const e=t=>{for(const s of t)this.flatRows.push(s),s.children&&!this.collapsed.has(s.code)&&e(s.children)};this.tree&&e(this.tree),this.flatRows.push({isGrandTotal:!0})}get _headerHeight(){return r.HEADER_HEIGHT*this._colTreeDepth()}_mount(){this.container.innerHTML="",this.container.classList.add("pg-root");const e=this.flatCols.length?this.flatCols:this.colKeys;this.totalWidth=this._colHeaderW+(e.length+1)*r.COL_W,this._mountColHeader(),this._mountScrollArea()}_mountColHeader(){const e=r.HEADER_HEIGHT,t=this._colHeaderW,s=r.COL_W,l=this._colTreeDepth(),o=e*l;this.headerEl=document.createElement("div"),this.headerEl.className="pg-col-header",this.headerEl.style.cssText=`
1
+ var PivotGridLib=(()=>{class r{static ROW_HEIGHT=24;static HEADER_HEIGHT=32;static COL_HEADER_W=200;static COL_W=150;static INDENT=16;static BUFFER=5;constructor({container:e,result:t,rows:s,columns:o,measure:i,fieldDefs:l={},labels:n={}}){this.container=e,this.rows=s,this.columns=o,this.measure=i,this.fieldDefs=l,this._labels=n,this._measureKey=i+"_sum",this._colHeaderW=r.COL_HEADER_W,this._hideSubtotals=!1,this.collapsed=new Set,this.collapsedCols=new Set,this.rowPool=[],this.rendered=new Map,this._applyResult(t),this._mount(),this._baseHeight=this.container.offsetHeight,this._renderVisible(),this._bindScroll()}_applyResult(e){this.cells=e.cells,this.colTree=e.colTree,this.colKeys=e.colKeys,this.tree=e.tree,this.grandTotal=e.grandTotal,e.measureKey&&(this._measureKey=e.measureKey),this._buildFlatCols(),this._buildFlatRows()}_buildFlatCols(){if(!this.colTree||!this.colTree.length){this.flatCols=[];return}const e=[],t=this.columns&&this.columns.length>1,s=o=>{for(const i of o)i.children?this.collapsedCols.has(i.code)?e.push({code:i.code,label:i.value,isSubtotal:!0,collapsed:!0}):(s(i.children),t&&!this._hideSubtotals&&e.push({code:i.code,label:"\u2211",isSubtotal:!0,collapsed:!1})):e.push({code:i.code,label:i.value,isSubtotal:!1})};s(this.colTree),this.flatCols=e}_getGroupSpan(e){if(!e.children||this.collapsedCols.has(e.code))return 1;let s=this.columns&&this.columns.length>1&&!this._hideSubtotals?1:0;for(const o of e.children)s+=this._getGroupSpan(o);return s}_colTreeDepth(){if(!this.colTree||!this.colTree.length)return 1;const e=t=>{let s=0;for(const o of t)o.children&&!this.collapsedCols.has(o.code)&&(s=Math.max(s,1+e(o.children)));return s};return 1+e(this.colTree)}_buildFlatRows(){this.flatRows=[];const e=t=>{for(const s of t)this.flatRows.push(s),s.children&&!this.collapsed.has(s.code)&&e(s.children)};this.tree&&e(this.tree),this.flatRows.push({isGrandTotal:!0})}get _headerHeight(){return r.HEADER_HEIGHT*this._colTreeDepth()}_mount(){this.container.innerHTML="",this.container.classList.add("pg-root");const e=this.flatCols.length?this.flatCols:this.colKeys;this.totalWidth=this._colHeaderW+(e.length+1)*r.COL_W,this._mountColHeader(),this._mountScrollArea()}_mountColHeader(){const e=r.HEADER_HEIGHT,t=this._colHeaderW,s=r.COL_W,o=this._colTreeDepth(),i=e*o;this.headerEl=document.createElement("div"),this.headerEl.className="pg-col-header",this.headerEl.style.cssText=`
2
2
  position: absolute; top: 0; left: 0;
3
- width: ${this.totalWidth}px; height: ${o}px;
3
+ width: ${this.totalWidth}px; height: ${i}px;
4
4
  background: #fafafa; border-bottom: 1px solid #d0d0d0; z-index: 10;
5
- `;const i=this._absCell({x:0,y:0,w:t,h:o,text:"",cls:"row-label"});this.rows.forEach((a,d)=>{const h=document.createElement("span"),u=(this.fieldDefs||{})[a]||{};if(h.textContent=u.title||u.label||a,h.style.cssText="cursor:pointer; padding: 0 2px;",h.title=`Expand to "${a}"`,d<this.rows.length-1?h.addEventListener("click",()=>this.expandToDepth(d+1)):h.style.cursor="default",d>0){const f=document.createElement("span");f.textContent=" \u203A ",f.style.color="#ccc",i.appendChild(f)}i.appendChild(h)});const n=document.createElement("div");if(n.className="pg-col-resize-handle",n.style.cssText=`
5
+ `;const l=this._absCell({x:0,y:0,w:t,h:i,text:"",cls:"row-label"});this.rows.forEach((a,d)=>{const h=document.createElement("span"),u=(this.fieldDefs||{})[a]||{};if(h.textContent=u.title||u.label||a,h.style.cssText="cursor:pointer; padding: 0 2px;",h.title=`Expand to "${a}"`,d<this.rows.length-1?h.addEventListener("click",()=>this.expandToDepth(d+1)):h.style.cursor="default",d>0){const f=document.createElement("span");f.textContent=" \u203A ",f.style.color="#ccc",l.appendChild(f)}l.appendChild(h)});const n=document.createElement("div");if(n.className="pg-col-resize-handle",n.style.cssText=`
6
6
  position: absolute; top: 0; left: ${t-4}px;
7
- width: 8px; height: ${o}px;
7
+ width: 8px; height: ${i}px;
8
8
  cursor: col-resize; z-index: 20;
9
- `,this.headerEl.appendChild(n),this._bindResizeHandle(n),this.colTree&&this.colTree.length){let a=0;for(const d of this.colTree)a=this._renderColNode(d,0,a,l)}const c=this.flatCols.length?this.flatCols:this.colKeys;this._absCell({x:t+c.length*s,y:0,w:s,h:o,text:this._labels.total||"Total",cls:"total-col"}),this.container.appendChild(this.headerEl)}_renderColNode(e,t,s,l){const o=r.HEADER_HEIGHT,i=this._colHeaderW,n=r.COL_W,c=this.collapsedCols.has(e.code),a=!e.children,d=this._getGroupSpan(e),h=a||c?(l-t)*o:o,u=c?"subtotal-col":a?"":"pg-col-header-group",f=this._absCell({x:i+s*n,y:t*o,w:d*n,h,text:e.value,cls:u});if(e.children){const p=document.createElement("span");p.className="pg-toggle"+(c?" collapsed":""),p.textContent="\u25BE",p.addEventListener("click",_=>{_.stopPropagation(),this._toggleColCollapse(e.code)}),f.insertBefore(p,f.firstChild)}if(!a&&!c){let p=s;for(const _ of e.children)p=this._renderColNode(_,t+1,p,l);if(this.columns&&this.columns.length>1&&!this._hideSubtotals){const _=(l-t-1)*o;_>0&&this._absCell({x:i+(s+d-1)*n,y:(t+1)*o,w:n,h:_,text:"\u2211",cls:"subtotal-col"})}}return s+d}_absCell({x:e,y:t,w:s,h:l,text:o,cls:i}){const n=document.createElement("div");return n.className="pg-col-header-cell"+(i?" "+i:""),n.style.cssText=`
9
+ `,this.headerEl.appendChild(n),this._bindResizeHandle(n),this.colTree&&this.colTree.length){let a=0;for(const d of this.colTree)a=this._renderColNode(d,0,a,o)}const c=this.flatCols.length?this.flatCols:this.colKeys;this._absCell({x:t+c.length*s,y:0,w:s,h:i,text:this._labels.total||"Total",cls:"total-col"}),this.container.appendChild(this.headerEl)}_renderColNode(e,t,s,o){const i=r.HEADER_HEIGHT,l=this._colHeaderW,n=r.COL_W,c=this.collapsedCols.has(e.code),a=!e.children,d=this._getGroupSpan(e),h=a||c?(o-t)*i:i,u=c?"subtotal-col":a?"":"pg-col-header-group",f=this._absCell({x:l+s*n,y:t*i,w:d*n,h,text:e.value,cls:u});if(e.children){const p=document.createElement("span");p.className="pg-toggle"+(c?" collapsed":""),p.textContent="\u25BE",p.addEventListener("click",_=>{_.stopPropagation(),this._toggleColCollapse(e.code)}),f.insertBefore(p,f.firstChild)}if(!a&&!c){let p=s;for(const _ of e.children)p=this._renderColNode(_,t+1,p,o);if(this.columns&&this.columns.length>1&&!this._hideSubtotals){const _=(o-t-1)*i;_>0&&this._absCell({x:l+(s+d-1)*n,y:(t+1)*i,w:n,h:_,text:"\u2211",cls:"subtotal-col"})}}return s+d}_absCell({x:e,y:t,w:s,h:o,text:i,cls:l}){const n=document.createElement("div");return n.className="pg-col-header-cell"+(l?" "+l:""),n.style.cssText=`
10
10
  position: absolute;
11
11
  left: ${e}px; top: ${t}px;
12
- width: ${s}px; height: ${l}px;
12
+ width: ${s}px; height: ${o}px;
13
13
  box-sizing: border-box;
14
- `,n.textContent=o,this.headerEl.appendChild(n),n}_mountScrollArea(){const e=this._headerHeight;this.scrollArea=document.createElement("div"),this.scrollArea.className="pg-scroll",this.scrollArea.style.top=e+"px",this.container.appendChild(this.scrollArea),this.virtualSpace=document.createElement("div"),this.virtualSpace.style.cssText=`
14
+ `,n.textContent=i,this.headerEl.appendChild(n),n}_mountScrollArea(){const e=this._headerHeight;this.scrollArea=document.createElement("div"),this.scrollArea.className="pg-scroll",this.scrollArea.style.top=e+"px",this.container.appendChild(this.scrollArea),this.virtualSpace=document.createElement("div"),this.virtualSpace.style.cssText=`
15
15
  position: relative;
16
16
  width: ${this.totalWidth}px;
17
17
  height: ${this.flatRows.length*r.ROW_HEIGHT}px;
18
- `,this.scrollArea.appendChild(this.virtualSpace)}_renderVisible(){const e=this.scrollArea.clientHeight,t=this.scrollArea.scrollTop,s=r.ROW_HEIGHT,l=r.BUFFER,o=Math.max(0,Math.floor(t/s)-l),i=Math.min(this.flatRows.length-1,Math.ceil((t+e)/s)+l);for(const[n,c]of this.rendered)(n<o||n>i)&&(this.virtualSpace.removeChild(c),this._recycleRow(c),this.rendered.delete(n));for(let n=o;n<=i;n++){if(this.rendered.has(n))continue;const c=this._acquireRow();this._fillRow(c,this.flatRows[n],n),this.virtualSpace.appendChild(c),this.rendered.set(n,c)}}_acquireRow(){if(this.rowPool.length){const t=this.rowPool.pop();return t.className="pg-row",t.removeAttribute("style"),t.innerHTML="",t}const e=document.createElement("div");return e.className="pg-row",e}_recycleRow(e){this.rowPool.push(e)}_fillRow(e,t,s){const l=r.ROW_HEIGHT;if(e.style.top=s*l+"px",e.style.width=this.totalWidth+"px",e.style.height=l+"px",t.isGrandTotal){e.classList.add("grand-total"),this._fillGrandTotalRow(e);return}e.style.background=s%2===0?"#ffffff":"#fcfcfc",this._fillHeaderCell(e,t),this._fillValueCells(e,t)}_fillHeaderCell(e,t){const s=r.ROW_HEIGHT,l=this._colHeaderW,o=r.INDENT,i=document.createElement("div");if(i.className="pg-cell-header",i.style.cssText=`width:${l}px;height:${s}px;padding-left:${8+t.depth*o}px`,t.children){const c=document.createElement("span");c.className="pg-toggle"+(this.collapsed.has(t.code)?" collapsed":""),c.textContent="\u25BE",c.addEventListener("click",a=>{a.stopPropagation(),this._toggleCollapse(t.code)}),i.appendChild(c)}else{const c=document.createElement("span");c.className="pg-toggle-spacer",i.appendChild(c)}const n=document.createElement("span");n.className=`pg-label depth-${Math.min(t.depth,2)}`,n.textContent=t.value,i.appendChild(n),e.appendChild(i)}_fillValueCells(e,t){const s=r.ROW_HEIGHT,l=r.COL_W,o=this.flatCols.length?this.flatCols:this.colKeys;for(const a of o){const d=t.code+"||"+a.code,h=this.cells.get(d),u=document.createElement("div");u.className="pg-cell"+(h==null?" empty":"")+(a.isSubtotal?" subtotal":""),u.style.cssText=`width:${l}px;height:${s}px`,u.textContent=h!=null?this._fmt(h):"\u2014",h!=null&&u.addEventListener("click",()=>this._emitDrillthrough(t,a.code,h)),e.appendChild(u)}const i=t.code+"||__total__",n=this.cells.get(i)||0,c=document.createElement("div");c.className="pg-cell total",c.style.cssText=`width:${l}px;height:${s}px`,c.textContent=this._fmt(n),c.addEventListener("click",()=>this._emitDrillthrough(t,"__total__",n)),e.appendChild(c)}_fillGrandTotalRow(e){const t=r.ROW_HEIGHT,s=this._colHeaderW,l=r.COL_W,o=this.flatCols.length?this.flatCols:this.colKeys,i=document.createElement("div");i.className="pg-cell-header",i.style.cssText=`width:${s}px;height:${t}px;padding-left:8px`;const n=document.createElement("span");n.className="pg-toggle-spacer",i.appendChild(n);const c=document.createElement("span");c.className="pg-label depth-0",c.textContent=this._labels.total||"Total",i.appendChild(c),e.appendChild(i);for(const d of o){const h="__grand__||"+d.code,u=this.cells.get(h)||0,f=document.createElement("div");f.className="pg-cell total"+(d.isSubtotal?" subtotal":""),f.style.cssText=`width:${l}px;height:${t}px`,f.textContent=this._fmt(u),f.addEventListener("click",()=>this._emitDrillthrough({isGrandTotal:!0},d.code,u)),e.appendChild(f)}const a=document.createElement("div");a.className="pg-cell total grand-total-val",a.style.cssText=`width:${l}px;height:${t}px`,a.textContent=this._fmt(this.grandTotal||0),a.addEventListener("click",()=>this._emitDrillthrough({isGrandTotal:!0},"__total__",this.grandTotal)),e.appendChild(a)}_toggleColCollapse(e){if(this.collapsedCols.has(e)){this.collapsedCols.delete(e);const t=this._findColNode(e);if(t?.children)for(const s of t.children)s.children&&this.collapsedCols.add(s.code)}else this.collapsedCols.add(e);this._rebuildCols()}_findColNode(e,t=this.colTree){if(!t)return null;for(const s of t){if(s.code===e)return s;const l=this._findColNode(e,s.children);if(l)return l}return null}toggleSubtotals(e){this._hideSubtotals=!e,this._rebuildCols()}_rebuildCols(){this._buildFlatCols();const e=this.flatCols.length?this.flatCols:this.colKeys;this.totalWidth=this._colHeaderW+(e.length+1)*r.COL_W,this.virtualSpace.style.width=this.totalWidth+"px",this.scrollArea.style.top=this._headerHeight+"px",this.headerEl.remove(),this._mountColHeader(),this.headerEl.style.transform=`translateX(-${this.scrollArea.scrollLeft}px)`,this._redraw()}_redraw(){this.virtualSpace.style.height=this.flatRows.length*r.ROW_HEIGHT+"px";for(const[,e]of this.rendered)this.virtualSpace.removeChild(e),this._recycleRow(e);this.rendered.clear(),this._renderVisible()}_bindScroll(){let e=!1;this.scrollArea.addEventListener("scroll",()=>{this.headerEl.style.transform=`translateX(-${this.scrollArea.scrollLeft}px)`,e||(requestAnimationFrame(()=>{this._renderVisible(),e=!1}),e=!0)})}_emitDrillthrough(e,t,s){const l={};if(!e.isGrandTotal){const o=this._getNodeChain(e);for(let i=0;i<o.length;i++)l[this.rows[i]]=o[i].value}if(t!=="__total__"){const o=t.split("\u2192");for(let i=0;i<o.length;i++)this.columns[i]&&(l[this.columns[i]]=o[i])}this.container.dispatchEvent(new CustomEvent("drillthrough",{bubbles:!0,detail:{context:l,value:s}}))}_getNodeChain(e){const t=[e];if(e.depth===0)return t;const s=this.flatRows.indexOf(e);for(let l=s-1;l>=0;l--){const o=this.flatRows[l];if(!o.isGrandTotal&&o.depth===e.depth-1){if(t.unshift(o),o.depth===0)break;e=o}}return t}_fmt(e){return new Intl.NumberFormat("ru-RU",{maximumFractionDigits:0}).format(e)}_bindResizeHandle(e){e.addEventListener("mousedown",t=>{t.preventDefault();const s=t.clientX,l=this._colHeaderW,o=n=>{const c=Math.max(r.COL_HEADER_W,l+n.clientX-s);this._colHeaderW=c,this._rebuild()},i=()=>{document.removeEventListener("mousemove",o),document.removeEventListener("mouseup",i)};document.addEventListener("mousemove",o),document.addEventListener("mouseup",i)})}_rebuild(){this.headerEl?.remove(),this.headerEl=null,this._buildFlatCols(),this._mountColHeader();for(const[,e]of this.rendered)this._recycleRow(e);this.rendered.clear(),this._renderVisible()}setResult(e,{rows:t,columns:s,measure:l,fieldDefs:o}={}){if(t&&(this.rows=t),s&&(this.columns=s),l&&(this.measure=l),o&&(this.fieldDefs=o),this.collapsedCols.clear(),this._applyResult(e),this.colTree){for(const n of this.colTree)n.children&&this.collapsedCols.add(n.code);this._buildFlatCols()}const i=this.flatCols.length?this.flatCols:this.colKeys;this.totalWidth=this._colHeaderW+(i.length+1)*r.COL_W,this.virtualSpace.style.width=this.totalWidth+"px",this.scrollArea.style.top=this._headerHeight+"px",this.headerEl.remove(),this._mountColHeader(),this._redraw()}collapseAll(){const e=t=>{if(t)for(const s of t)s.children&&(this.collapsed.add(s.code),e(s.children))};e(this.tree),this._buildFlatRows(),this._redraw()}static _detectMaxHeight(){const e=document.createElement("div");e.style.cssText="position:fixed;visibility:hidden;",document.body.appendChild(e);let t=1e6;for(;t<1e8&&(e.style.height=t+"px",!(e.offsetHeight<t));)t*=2;return e.remove(),t/2}static MAX_FLAT_ROWS=Math.floor(r._detectMaxHeight()/r.ROW_HEIGHT);_confirmLargeExpand(e,t,s){const l=(e/1e6).toFixed(1),o=(this._labels.confirmLargeExpand||"Too many rows (~{millions}M). Click OK to expand anyway.").replace("{millions}",l);window.confirm(o)?t():s?.()}_toggleCollapse(e){const t=this.collapsed.has(e);if(t?this.collapsed.delete(e):this.collapsed.add(e),this._buildFlatRows(),t&&this.flatRows.length>r.MAX_FLAT_ROWS){this._confirmLargeExpand(this.flatRows.length,()=>this._redraw(),()=>{this.collapsed.add(e),this._buildFlatRows()});return}this._redraw()}expandToDepth(e){const t=[],s=o=>{for(const i of o)i.children&&(i.depth<e-1?(this.collapsed.delete(i.code),s(i.children)):i.depth===e-1&&t.push(i))};s(this.tree);const l=t.some(o=>!this.collapsed.has(o.code));for(const o of t)l?this.collapsed.add(o.code):this.collapsed.delete(o.code);this._buildFlatRows(),this._redraw()}expandAll(){if(this.collapsed.clear(),this._buildFlatRows(),this.flatRows.length>r.MAX_FLAT_ROWS){this._confirmLargeExpand(this.flatRows.length,()=>this._redraw());return}this._redraw()}expandAllCols(){this.collapsedCols.clear(),this._rebuildCols()}collapseAllCols(){const e=t=>{if(t)for(const s of t)s.children&&(this.collapsedCols.add(s.code),e(s.children))};e(this.colTree),this._rebuildCols()}}})();
18
+ `,this.scrollArea.appendChild(this.virtualSpace)}_renderVisible(){const e=this.scrollArea.clientHeight,t=this.scrollArea.scrollTop,s=r.ROW_HEIGHT,o=r.BUFFER,i=Math.max(0,Math.floor(t/s)-o),l=Math.min(this.flatRows.length-1,Math.ceil((t+e)/s)+o);for(const[n,c]of this.rendered)(n<i||n>l)&&(this.virtualSpace.removeChild(c),this._recycleRow(c),this.rendered.delete(n));for(let n=i;n<=l;n++){if(this.rendered.has(n))continue;const c=this._acquireRow();this._fillRow(c,this.flatRows[n],n),this.virtualSpace.appendChild(c),this.rendered.set(n,c)}}_acquireRow(){if(this.rowPool.length){const t=this.rowPool.pop();return t.className="pg-row",t.removeAttribute("style"),t.innerHTML="",t}const e=document.createElement("div");return e.className="pg-row",e}_recycleRow(e){this.rowPool.push(e)}_fillRow(e,t,s){const o=r.ROW_HEIGHT;if(e.style.top=s*o+"px",e.style.width=this.totalWidth+"px",e.style.height=o+"px",t.isGrandTotal){e.classList.add("grand-total"),this._fillGrandTotalRow(e);return}e.style.background=s%2===0?"#ffffff":"#fcfcfc",this._fillHeaderCell(e,t),this._fillValueCells(e,t)}_fillHeaderCell(e,t){const s=r.ROW_HEIGHT,o=this._colHeaderW,i=r.INDENT,l=document.createElement("div");if(l.className="pg-cell-header",l.style.cssText=`width:${o}px;height:${s}px;padding-left:${8+t.depth*i}px`,t.children){const c=document.createElement("span");c.className="pg-toggle"+(this.collapsed.has(t.code)?" collapsed":""),c.textContent="\u25BE",c.addEventListener("click",a=>{a.stopPropagation(),this._toggleCollapse(t.code)}),l.appendChild(c)}else{const c=document.createElement("span");c.className="pg-toggle-spacer",l.appendChild(c)}const n=document.createElement("span");n.className=`pg-label depth-${Math.min(t.depth,2)}`,n.textContent=t.value,l.appendChild(n),e.appendChild(l)}_fillValueCells(e,t){const s=r.ROW_HEIGHT,o=r.COL_W,i=this.flatCols.length?this.flatCols:this.colKeys;for(const a of i){const d=t.code+"||"+a.code,h=this.cells.get(d),u=document.createElement("div");u.className="pg-cell"+(h==null?" empty":"")+(a.isSubtotal?" subtotal":""),u.style.cssText=`width:${o}px;height:${s}px`,u.textContent=h!=null?this._fmt(h):"\u2014",h!=null&&u.addEventListener("click",()=>this._emitDrillthrough(t,a.code,h)),e.appendChild(u)}const l=t.code+"||__total__",n=this.cells.get(l)||0,c=document.createElement("div");c.className="pg-cell total",c.style.cssText=`width:${o}px;height:${s}px`,c.textContent=this._fmt(n),c.addEventListener("click",()=>this._emitDrillthrough(t,"__total__",n)),e.appendChild(c)}_fillGrandTotalRow(e){const t=r.ROW_HEIGHT,s=this._colHeaderW,o=r.COL_W,i=this.flatCols.length?this.flatCols:this.colKeys,l=document.createElement("div");l.className="pg-cell-header",l.style.cssText=`width:${s}px;height:${t}px;padding-left:8px`;const n=document.createElement("span");n.className="pg-toggle-spacer",l.appendChild(n);const c=document.createElement("span");c.className="pg-label depth-0",c.textContent=this._labels.total||"Total",l.appendChild(c),e.appendChild(l);for(const d of i){const h="__grand__||"+d.code,u=this.cells.get(h)||0,f=document.createElement("div");f.className="pg-cell total"+(d.isSubtotal?" subtotal":""),f.style.cssText=`width:${o}px;height:${t}px`,f.textContent=this._fmt(u),f.addEventListener("click",()=>this._emitDrillthrough({isGrandTotal:!0},d.code,u)),e.appendChild(f)}const a=document.createElement("div");a.className="pg-cell total grand-total-val",a.style.cssText=`width:${o}px;height:${t}px`,a.textContent=this._fmt(this.grandTotal||0),a.addEventListener("click",()=>this._emitDrillthrough({isGrandTotal:!0},"__total__",this.grandTotal)),e.appendChild(a)}_toggleColCollapse(e){if(this.collapsedCols.has(e)){this.collapsedCols.delete(e);const t=this._findColNode(e);if(t?.children)for(const s of t.children)s.children&&this.collapsedCols.add(s.code)}else this.collapsedCols.add(e);this._rebuildCols()}_findColNode(e,t=this.colTree){if(!t)return null;for(const s of t){if(s.code===e)return s;const o=this._findColNode(e,s.children);if(o)return o}return null}toggleSubtotals(e){this._hideSubtotals=!e,this._rebuildCols()}_rebuildCols(){this._buildFlatCols();const e=this.flatCols.length?this.flatCols:this.colKeys;this.totalWidth=this._colHeaderW+(e.length+1)*r.COL_W,this.virtualSpace.style.width=this.totalWidth+"px",this.scrollArea.style.top=this._headerHeight+"px",this.headerEl.remove(),this._mountColHeader(),this.headerEl.style.transform=`translateX(-${this.scrollArea.scrollLeft}px)`,this._redraw()}_redraw(){this.virtualSpace.style.height=this.flatRows.length*r.ROW_HEIGHT+"px";for(const[,e]of this.rendered)this.virtualSpace.removeChild(e),this._recycleRow(e);this.rendered.clear(),this._renderVisible()}_bindScroll(){let e=!1;this.scrollArea.addEventListener("scroll",()=>{this.headerEl.style.transform=`translateX(-${this.scrollArea.scrollLeft}px)`,e||(requestAnimationFrame(()=>{this._renderVisible(),e=!1}),e=!0)})}_emitDrillthrough(e,t,s){const o={};if(!e.isGrandTotal){const i=this._getNodeChain(e);for(let l=0;l<i.length;l++)o[this.rows[l]]=i[l].value}if(t!=="__total__"){const i=t.split("\u2192");for(let l=0;l<i.length;l++)this.columns[l]&&(o[this.columns[l]]=i[l])}this.container.dispatchEvent(new CustomEvent("drillthrough",{bubbles:!0,detail:{context:o,value:s}}))}_getNodeChain(e){const t=[e];if(e.depth===0)return t;const s=this.flatRows.indexOf(e);for(let o=s-1;o>=0;o--){const i=this.flatRows[o];if(!i.isGrandTotal&&i.depth===e.depth-1){if(t.unshift(i),i.depth===0)break;e=i}}return t}_fmt(e){return new Intl.NumberFormat("ru-RU",{maximumFractionDigits:0}).format(e)}growHeight(){const e=this.container.offsetHeight;this.container.style.flex="0 0 auto",this.container.style.height=e+this._baseHeight+"px",this._renderVisible()}shrinkHeight(){const t=this.container.offsetHeight-this._baseHeight;return t<=this._baseHeight?(this.container.style.flex="1",this.container.style.height="",this._renderVisible(),!0):(this.container.style.flex="0 0 auto",this.container.style.height=t+"px",this._renderVisible(),!1)}_bindResizeHandle(e){e.addEventListener("mousedown",t=>{t.preventDefault();const s=t.clientX,o=this._colHeaderW,i=n=>{const c=Math.max(r.COL_HEADER_W,o+n.clientX-s);this._colHeaderW=c,this._rebuild()},l=()=>{document.removeEventListener("mousemove",i),document.removeEventListener("mouseup",l)};document.addEventListener("mousemove",i),document.addEventListener("mouseup",l)})}_rebuild(){this.headerEl?.remove(),this.headerEl=null,this._buildFlatCols(),this._mountColHeader();for(const[,e]of this.rendered)this._recycleRow(e);this.rendered.clear(),this._renderVisible()}setResult(e,{rows:t,columns:s,measure:o,fieldDefs:i}={}){if(t&&(this.rows=t),s&&(this.columns=s),o&&(this.measure=o),i&&(this.fieldDefs=i),this.collapsedCols.clear(),this._applyResult(e),this.colTree){for(const n of this.colTree)n.children&&this.collapsedCols.add(n.code);this._buildFlatCols()}const l=this.flatCols.length?this.flatCols:this.colKeys;this.totalWidth=this._colHeaderW+(l.length+1)*r.COL_W,this.virtualSpace.style.width=this.totalWidth+"px",this.scrollArea.style.top=this._headerHeight+"px",this.headerEl.remove(),this._mountColHeader(),this._redraw()}collapseAll(){const e=t=>{if(t)for(const s of t)s.children&&(this.collapsed.add(s.code),e(s.children))};e(this.tree),this._buildFlatRows(),this._redraw()}static _detectMaxHeight(){const e=document.createElement("div");e.style.cssText="position:fixed;visibility:hidden;",document.body.appendChild(e);let t=1e6;for(;t<1e8&&(e.style.height=t+"px",!(e.offsetHeight<t));)t*=2;return e.remove(),t/2}static MAX_FLAT_ROWS=Math.floor(r._detectMaxHeight()/r.ROW_HEIGHT);_confirmLargeExpand(e,t,s){const o=(e/1e6).toFixed(1),i=(this._labels.confirmLargeExpand||"Too many rows (~{millions}M). Click OK to expand anyway.").replace("{millions}",o);window.confirm(i)?t():s?.()}_toggleCollapse(e){const t=this.collapsed.has(e);if(t?this.collapsed.delete(e):this.collapsed.add(e),this._buildFlatRows(),t&&this.flatRows.length>r.MAX_FLAT_ROWS){this._confirmLargeExpand(this.flatRows.length,()=>this._redraw(),()=>{this.collapsed.add(e),this._buildFlatRows()});return}this._redraw()}expandToDepth(e){const t=[],s=i=>{for(const l of i)l.children&&(l.depth<e-1?(this.collapsed.delete(l.code),s(l.children)):l.depth===e-1&&t.push(l))};s(this.tree);const o=t.some(i=>!this.collapsed.has(i.code));for(const i of t)o?this.collapsed.add(i.code):this.collapsed.delete(i.code);this._buildFlatRows(),this._redraw()}expandAll(){if(this.collapsed.clear(),this._buildFlatRows(),this.flatRows.length>r.MAX_FLAT_ROWS){this._confirmLargeExpand(this.flatRows.length,()=>this._redraw());return}this._redraw()}expandAllCols(){this.collapsedCols.clear(),this._rebuildCols()}collapseAllCols(){const e=t=>{if(t)for(const s of t)s.children&&(this.collapsedCols.add(s.code),e(s.children))};e(this.colTree),this._rebuildCols()}}})();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pivotgrid-js",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Vanilla JS pivot table — no dependencies, no frameworks",
5
5
  "author": "Aleksandr Korolev <korolevalexa@gmail.com>",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -271,19 +271,29 @@ class RestProvider {
271
271
  // ── HTTP ───────────────────────────────────────────────────────────────────
272
272
 
273
273
  async _execute(query) {
274
- const res = await fetch(this.url, {
275
- method: 'POST',
276
- headers: { 'Content-Type': 'application/json' },
277
- body: JSON.stringify({ query }),
278
- });
274
+ let page = 0;
275
+ let allRows = [];
276
+ let hasMore = true;
277
+
278
+ while (hasMore) {
279
+ const res = await fetch(this.url, {
280
+ method: 'POST',
281
+ headers: { 'Content-Type': 'application/json' },
282
+ body: JSON.stringify({ query, page }),
283
+ });
284
+
285
+ if (!res.ok) {
286
+ const err = await res.json().catch(() => ({}));
287
+ throw new Error(`Server error ${res.status}: ${err.error || ''}`);
288
+ }
279
289
 
280
- if (!res.ok) {
281
- const err = await res.json().catch(() => ({}));
282
- throw new Error(`Server error ${res.status}: ${err.error || ''}`);
290
+ const data = await res.json();
291
+ allRows = allRows.concat(data.rows);
292
+ hasMore = data.hasMore;
293
+ page++;
283
294
  }
284
295
 
285
- const rows = await res.json();
286
- return rows.map(row => {
296
+ return allRows.map(row => {
287
297
  const out = {};
288
298
  for (const k of Object.keys(row)) out[k.toLowerCase()] = row[k];
289
299
  return out;
package/server/README.md CHANGED
@@ -42,7 +42,7 @@ All settings can also be configured via the Config Editor UI.
42
42
 
43
43
  | Method | Path | Description |
44
44
  |--------|------|-------------|
45
- | `POST` | `/query` | Execute a SELECT query, returns JSON array |
45
+ | `POST` | `/query` | Execute a SELECT query, returns paginated JSON (`{rows, total, page, hasMore}`) |
46
46
  | `GET` | `/configs` | List all config names |
47
47
  | `GET` | `/configs/{name}` | Get a config by name |
48
48
  | `POST` | `/configs/{name}` | Save a config by name |
@@ -50,6 +50,30 @@ All settings can also be configured via the Config Editor UI.
50
50
  | `POST` | `/server-config` | Save DB settings to `.env` |
51
51
  | `POST` | `/test-connection` | Test DB connection with given credentials |
52
52
 
53
+ ## Query Pagination
54
+
55
+ Large result sets are split into pages to avoid sending huge HTTP responses
56
+ (which can fail or time out on slower connections).
57
+
58
+ The query runs **once** — results are cached in memory on the server and
59
+ sliced into pages as the client requests them, so a single `GROUP BY` is
60
+ never re-executed for each page.
61
+
62
+ ```python
63
+ _PAGE_SIZE = 200_000 # rows per page sent to the client
64
+ _QUERY_CACHE_TTL = 300 # seconds a query's results stay in server memory
65
+ ```
66
+
67
+ - `_PAGE_SIZE` — increase if your network/hardware comfortably handles
68
+ larger responses; decrease if you see slow or failing requests on big
69
+ datasets.
70
+ - `_QUERY_CACHE_TTL` — how long the server keeps a query's full result in
71
+ memory while the client fetches subsequent pages. Stale entries are
72
+ cleaned up automatically.
73
+
74
+ The client (`RestProvider`) handles pagination transparently — it requests
75
+ pages in a loop and concatenates them into a single array.
76
+
53
77
  ## Configs
54
78
 
55
79
  Configs are stored as JSON files in `server/configs/`:
@@ -106,7 +106,7 @@
106
106
  ],
107
107
  "measure": "revenue",
108
108
  "func": "sum",
109
- "maxCachedRows": 500000,
109
+ "maxCachedRows": 1000000,
110
110
  "filterCheckboxLimit": 30,
111
111
  "drillthroughQuery": "\n SELECT ID,REGION FROM sales_data\n WHERE {filters}\n LIMIT 200 OFFSET 0\n "
112
112
  }
package/server/server.py CHANGED
@@ -20,6 +20,8 @@ Start:
20
20
  python server.py
21
21
  """
22
22
 
23
+ import time
24
+ import hashlib
23
25
  import json
24
26
  import gzip
25
27
  import os
@@ -38,6 +40,18 @@ CONNECTORS_DIR = os.path.join(BASE_DIR, 'connectors')
38
40
 
39
41
  os.makedirs(CONFIGS_DIR, exist_ok=True)
40
42
 
43
+ _QUERY_CACHE = {} # query hash → {'rows': [...], 'ts': last access time}
44
+ _QUERY_CACHE_TTL = 300 # seconds — how long a cached query result stays in memory
45
+ _PAGE_SIZE = 200_000 # rows per page sent to the client in one HTTP response
46
+
47
+ def _cache_key(query):
48
+ return hashlib.sha256(query.encode('utf-8')).hexdigest()
49
+
50
+ def _cleanup_cache():
51
+ now = time.time()
52
+ for k in [k for k, v in _QUERY_CACHE.items() if now - v['ts'] > _QUERY_CACHE_TTL]:
53
+ del _QUERY_CACHE[k]
54
+
41
55
  # ── Load .env ──────────────────────────────────────────────────────────────────
42
56
 
43
57
  def load_env(path):
@@ -237,10 +251,27 @@ class Handler(BaseHTTPRequestHandler):
237
251
  self._send(400, json.dumps({'error': 'Only SELECT allowed'}))
238
252
  return
239
253
 
254
+ page = int(payload.get('page', 0))
255
+ key = _cache_key(query)
256
+
240
257
  try:
241
- connector = get_active_connector()
242
- rows = connector.execute_query(query)
243
- self._send(200, json.dumps(rows, ensure_ascii=False, default=str))
258
+ if page == 0 or key not in _QUERY_CACHE:
259
+ _cleanup_cache()
260
+ connector = get_active_connector()
261
+ rows = connector.execute_query(query)
262
+ _QUERY_CACHE[key] = {'rows': rows, 'ts': time.time()}
263
+ else:
264
+ _QUERY_CACHE[key]['ts'] = time.time()
265
+
266
+ rows = _QUERY_CACHE[key]['rows']
267
+ total = len(rows)
268
+ start = page * _PAGE_SIZE
269
+ chunk = rows[start:start + _PAGE_SIZE]
270
+ has_more = start + _PAGE_SIZE < total
271
+
272
+ self._send(200, json.dumps({
273
+ 'rows': chunk, 'total': total, 'page': page, 'hasMore': has_more,
274
+ }, ensure_ascii=False, default=str))
244
275
  except Exception as e:
245
276
  self._send(500, json.dumps({'error': str(e)}))
246
277