@yrest/cli 0.11.0 → 0.11.1

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.
package/dist/index.js CHANGED
@@ -867,9 +867,15 @@ function generateERData(storage) {
867
867
  const relations = storage.getRelations();
868
868
  const schema = storage.getSchema();
869
869
  const pivotTables = /* @__PURE__ */ new Set();
870
+ const pivotFKs = /* @__PURE__ */ new Map();
870
871
  for (const fields of Object.values(relations)) {
871
872
  for (const def of Object.values(fields)) {
872
- if (def.type === "many2many") pivotTables.add(def.through);
873
+ if (def.type === "many2many") {
874
+ pivotTables.add(def.through);
875
+ if (!pivotFKs.has(def.through)) pivotFKs.set(def.through, /* @__PURE__ */ new Set());
876
+ pivotFKs.get(def.through).add(def.foreignKey);
877
+ pivotFKs.get(def.through).add(def.otherKey);
878
+ }
873
879
  }
874
880
  }
875
881
  const fkFields = /* @__PURE__ */ new Map();
@@ -905,6 +911,9 @@ function generateERData(storage) {
905
911
  for (const [source, fields] of Object.entries(relations)) {
906
912
  for (const [key, def] of Object.entries(fields)) {
907
913
  if (def.type === "many2many") {
914
+ const throughRels = relations[def.through] ?? {};
915
+ const fkRel = throughRels[def.foreignKey];
916
+ const otherRel = throughRels[def.otherKey];
908
917
  erRelations.push({
909
918
  source,
910
919
  target: def.target,
@@ -912,9 +921,14 @@ function generateERData(storage) {
912
921
  label: def.through,
913
922
  through: def.through,
914
923
  foreignKey: def.foreignKey,
915
- otherKey: def.otherKey
924
+ otherKey: def.otherKey,
925
+ fkCarDirect: fkRel?.carDirect,
926
+ fkCarInverse: fkRel?.carInverse,
927
+ otherCarDirect: otherRel?.carDirect,
928
+ otherCarInverse: otherRel?.carInverse
916
929
  });
917
930
  } else {
931
+ if (pivotFKs.get(source)?.has(key)) continue;
918
932
  erRelations.push({
919
933
  source,
920
934
  target: def.target,
@@ -941,7 +955,15 @@ function erDiagramSection(data) {
941
955
 
942
956
  <div class="card" style="padding:0;overflow:hidden;position:relative;">
943
957
  <canvas id="er-canvas" style="width:100%;display:block;cursor:grab;"></canvas>
944
- <button id="er-dl" title="Download PNG" style="position:absolute;top:10px;right:14px;background:#161b22;border:1px solid #30363d;color:#7d8590;padding:4px 10px;border-radius:6px;font-size:11px;cursor:pointer;line-height:1.4;">\u2193 PNG</button>
958
+ <div style="position:absolute;top:10px;right:14px;display:flex;gap:6px;align-items:center;">
959
+ <select id="er-scale" title="Download quality" style="background:#161b22;border:1px solid #30363d;color:#7d8590;padding:3px 6px;border-radius:6px;font-size:11px;cursor:pointer;">
960
+ <option value="1">1\xD7</option>
961
+ <option value="2">2\xD7</option>
962
+ <option value="3" selected>3\xD7</option>
963
+ <option value="4">4\xD7</option>
964
+ </select>
965
+ <button id="er-dl" title="Download PNG" style="background:#161b22;border:1px solid #30363d;color:#7d8590;padding:4px 10px;border-radius:6px;font-size:11px;cursor:pointer;line-height:1.4;">\u2193 PNG</button>
966
+ </div>
945
967
  </div>
946
968
 
947
969
  <script>${/*js*/
@@ -951,7 +973,7 @@ function erDiagramSection(data) {
951
973
  var D=${json};
952
974
 
953
975
  // \u2500\u2500 Constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
954
- var NW=178,HH=34,FH=22,FP=10,ROW_GAP=44,COL_GAP=100,BR=8,BO=14,LGH=56;
976
+ var NW=178,HH=34,FH=22,FP=10,ROW_GAP=44,COL_GAP=100,BR=12,BO=16,LGH=84,MIN_GAP=10;
955
977
 
956
978
  var C={bg:'#0d1117',nb:'#161b22',nh:'#1c2128',pb:'#0d1117',ph:'#161b22',
957
979
  bo:'#30363d',bs:'#58a6ff',tx:'#e6edf3',mu:'#7d8590',
@@ -991,6 +1013,30 @@ function erDiagramSection(data) {
991
1013
  }
992
1014
  });
993
1015
 
1016
+ // \u2500\u2500 Dynamic node width \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1017
+ function computeNodeWidths(){
1018
+ nodes.forEach(function(n){
1019
+ var e=n.e;
1020
+ ctx.font='600 12px -apple-system,BlinkMacSystemFont,sans-serif';
1021
+ var hdrW=FP+ctx.measureText(e.name).width+FP;
1022
+ if(e.isPivot){
1023
+ ctx.font='9px monospace';
1024
+ hdrW+=ctx.measureText('pivot').width+FP;
1025
+ }
1026
+ var maxRow=hdrW;
1027
+ e.fields.forEach(function(f){
1028
+ var leftPad=FP+(f.pk||f.fk?16:0);
1029
+ var rightPad=(f.required&&!f.pk?14:0)+FP;
1030
+ ctx.font=(f.required&&!f.pk?'600 ':'')+'11px monospace';
1031
+ var nw=ctx.measureText(f.name).width;
1032
+ ctx.font='10px monospace';
1033
+ var tw=ctx.measureText(f.type).width;
1034
+ maxRow=Math.max(maxRow,leftPad+nw+MIN_GAP+tw+rightPad);
1035
+ });
1036
+ n.w=Math.max(NW,Math.ceil(maxRow));
1037
+ });
1038
+ }
1039
+
994
1040
  // \u2500\u2500 Layered layout (simplified Sugiyama) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
995
1041
  var groups=[];
996
1042
 
@@ -1029,7 +1075,8 @@ function erDiagramSection(data) {
1029
1075
  median(adj[b].map(function(x){return np[x]!=null?np[x]:0;}));
1030
1076
  });
1031
1077
  }
1032
- var colW=NW+COL_GAP;
1078
+ var maxNW=nodes.reduce(function(m,n){return Math.max(m,n.w);},NW);
1079
+ var colW=maxNW+COL_GAP;
1033
1080
  var totalW=(maxL+1)*colW-COL_GAP;
1034
1081
  var startX=Math.max(20,(W-totalW)/2);
1035
1082
  for(var l=0;l<=maxL;l++){
@@ -1077,37 +1124,37 @@ function erDiagramSection(data) {
1077
1124
 
1078
1125
  var badges=[];
1079
1126
 
1080
- function pushBadge(x,y,ux,uy,many,col){
1081
- badges.push({x:x,y:y,ux:ux,uy:uy,many:many,col:col});
1127
+ // \u2500\u2500 SVG cardinality badges via Path2D \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1128
+ // SVG orientation: LEFT side touches the node, RIGHT side connects to the line.
1129
+ // Rotation = Math.atan2(uy,ux) aligns local +x with the outward (away-from-node) direction.
1130
+ var P2D={
1131
+ '1..1':new Path2D('M75 40C75 59.33 59.33 75 40 75C20.67 75 5 59.33 5 40C5 20.67 20.67 5 40 5C59.33 5 75 20.67 75 40ZM41 26C41 24.3431 39.6569 23 38 23C36.3431 23 35 24.3431 35 26V37H26V26C26 24.3431 24.6569 23 23 23C21.3431 23 20 24.3431 20 26V37H15C13.3431 37 12 38.3431 12 40C12 41.6569 13.3431 43 15 43H20V54C20 55.6569 21.3431 57 23 57C24.6569 57 26 55.6569 26 54V43H35V54C35 55.6569 36.3431 57 38 57C39.6569 57 41 55.6569 41 54V43H66C67.6569 43 69 41.6569 69 40C69 38.3431 67.6569 37 66 37H41V26Z'),
1132
+ '1..n':new Path2D('M40 75C59.33 75 75 59.33 75 40C75 20.67 59.33 5 40 5C20.67 5 5 20.67 5 40C5 59.33 20.67 75 40 75ZM44.9862 42.9025L18.6801 58.0904C17.2452 58.9188 15.4105 58.4272 14.582 56.9923C13.7536 55.5574 14.2452 53.7227 15.6801 52.8942L32.9862 42.9025H17C15.3431 42.9025 14 41.5594 14 39.9025C14 38.2457 15.3431 36.9025 17 36.9025H32.9862L15.5981 26.5986C14.1632 25.7702 13.6716 23.9354 14.5 22.5005C15.3284 21.0656 17.1632 20.574 18.5981 21.4024L44.9862 36.9025H46V26C46 24.3431 47.3431 23 49 23C50.6569 23 52 24.3431 52 26V36.9025H68C69.6569 36.9025 71 38.2457 71 39.9025C71 41.5594 69.6569 42.9025 68 42.9025H52V53C52 54.6569 50.6569 56 49 56C47.3431 56 46 54.6569 46 53V42.9025H44.9862Z'),
1133
+ '0..1':new Path2D('M75 40C75 59.33 59.33 75 40 75C20.67 75 5 59.33 5 40C5 20.67 20.67 5 40 5C59.33 5 75 20.67 75 40ZM23 57C24.6569 57 26 55.6569 26 54V43H33.3781C34.7102 48.1757 39.4085 52 45 52C50.5915 52 55.2898 48.1757 56.6219 43H66C67.6569 43 69 41.6569 69 40C69 38.3431 67.6569 37 66 37H56.6219C55.2898 31.8243 50.5915 28 45 28C39.4085 28 34.7102 31.8243 33.3781 37H26V26C26 24.3431 24.6569 23 23 23C21.3431 23 20 24.3431 20 26V37H15C13.3431 37 12 38.3431 12 40C12 41.6569 13.3431 43 15 43H20V54C20 55.6569 21.3431 57 23 57ZM51 40C51 41.0929 50.7078 42.1175 50.1973 43C49.1599 44.7934 47.2208 46 45 46C42.7792 46 40.8401 44.7934 39.8027 43C39.2922 42.1175 39 41.0929 39 40C39 38.9071 39.2922 37.8825 39.8027 37C40.8401 35.2066 42.7792 34 45 34C47.2208 34 49.1599 35.2066 50.1973 37C50.7078 37.8825 51 38.9071 51 40Z'),
1134
+ '0..n':new Path2D('M40 75C59.33 75 75 59.33 75 40C75 20.67 59.33 5 40 5C20.67 5 5 20.67 5 40C5 59.33 20.67 75 40 75ZM17 42.9025C15.3431 42.9025 14 41.5594 14 39.9025C14 38.2457 15.3431 36.9025 17 36.9025H32.9862L15.5981 26.5986C14.1632 25.7702 13.6716 23.9354 14.5 22.5005C15.3284 21.0656 17.1632 20.574 18.5981 21.4024L45.4091 36.8818L45.4036 36.9025H45.445L45.4091 36.8818C46.7819 31.7662 51.4511 28 57 28C62.579 28 67.2687 31.8072 68.6129 36.9652C69.9758 37.2481 71 38.4557 71 39.9025C71 41.331 70.0017 42.5262 68.6647 42.8286C67.3931 48.091 62.6531 52 57 52C51.3734 52 46.6513 48.1276 45.3534 42.9025H44.9862L18.6801 58.0904C17.2452 58.9188 15.4105 58.4272 14.582 56.9923C13.7536 55.5574 14.2452 53.7227 15.6801 52.8942L32.9862 42.9025H17ZM62.1397 36.9025C61.0891 35.163 59.1804 34 57 34C54.8196 34 52.9109 35.163 51.8603 36.9025C51.3142 37.8067 51 38.8667 51 40C51 41.0529 51.2712 42.0424 51.7475 42.9025C52.7704 44.7496 54.7392 46 57 46C59.2608 46 61.2296 44.7496 62.2525 42.9025C62.7288 42.0424 63 41.0529 63 40C63 38.8667 62.6858 37.8067 62.1397 36.9025Z'),
1135
+ 'n':new Path2D('M40 75C59.33 75 75 59.33 75 40C75 20.67 59.33 5 40 5C20.67 5 5 20.67 5 40C5 59.33 20.67 75 40 75ZM17 36.9025H33.0677L15.5981 26.5986C14.1632 25.7702 13.6716 23.9354 14.5 22.5005C15.3284 21.0656 17.1632 20.574 18.5981 21.4024L44.9862 36.9025H68C69.6569 36.9025 71 38.2457 71 39.9025C71 41.5594 69.6569 42.9025 68 42.9025H44.9862L18.6801 58.0904C17.2452 58.9188 15.4105 58.4272 14.582 56.9923C13.7536 55.5574 14.2452 53.7227 15.6801 52.8942L32.9862 42.9025H17C15.3431 42.9025 14 41.5594 14 39.9025C14 38.2457 15.3431 36.9025 17 36.9025Z')
1136
+ };
1137
+
1138
+ function pushBadge(x,y,ux,uy,car,col){
1139
+ badges.push({x:x,y:y,ux:ux,uy:uy,car:car,col:col});
1082
1140
  }
1083
1141
 
1084
- // Badge: solid colored circle; crow's foot (many) or double-bar (one) in dark ink inside.
1085
- // ux,uy points outward from the node \u2014 badge is offset BO px into the gap.
1086
- // Crow's foot tines open toward the node (-x in rotated frame), tip toward line (+x).
1087
1142
  function drawBadge(b){
1088
- var bx=b.x+b.ux*BO,by=b.y+b.uy*BO;
1089
- ctx.fillStyle=b.col;ctx.strokeStyle=b.col;ctx.lineWidth=1;
1090
- ctx.beginPath();ctx.arc(bx,by,BR,0,Math.PI*2);ctx.fill();ctx.stroke();
1091
- ctx.save();ctx.translate(bx,by);ctx.rotate(Math.atan2(b.uy,b.ux));
1092
- ctx.strokeStyle=C.bg;ctx.lineWidth=1.4;ctx.lineCap='round';
1093
- ctx.beginPath();
1094
- if(b.many){
1095
- ctx.moveTo(-5,-4);ctx.lineTo(5,0); // upper tine \u2192 tip
1096
- ctx.moveTo(-5,4);ctx.lineTo(5,0); // lower tine \u2192 tip
1097
- ctx.moveTo(-5,0);ctx.lineTo(5,0); // center line
1098
- ctx.moveTo(-5,-4);ctx.lineTo(-5,4); // base bar (near node)
1099
- }else{
1100
- ctx.moveTo(-3,-5);ctx.lineTo(-3,5); // near bar
1101
- ctx.moveTo(2,-5);ctx.lineTo(2,5); // far bar
1102
- }
1103
- ctx.stroke();
1143
+ var p=P2D[b.car]||P2D['n'],sc=BR/40;
1144
+ ctx.save();
1145
+ ctx.translate(b.x+b.ux*BO,b.y+b.uy*BO);
1146
+ ctx.rotate(Math.atan2(b.uy,b.ux));
1147
+ ctx.scale(sc,sc);
1148
+ ctx.translate(-40,-40);
1149
+ ctx.fillStyle=b.col;
1150
+ ctx.fill(p,'evenodd');
1104
1151
  ctx.restore();
1105
1152
  }
1106
1153
 
1107
1154
  function ecol(t){return t==='one2one'?C.o2o:t==='many2many'?C.m2m:C.m2o;}
1108
1155
 
1109
1156
  // offset: pixels to shift perpendicular to the edge direction (for parallel edges)
1110
- function drawEdgeLine(a,b,col,dashed,label,manyA,manyB,off){
1157
+ function drawEdgeLine(a,b,col,dashed,label,carA,carB,off){
1111
1158
  var p1=edgePt(a,b.x+b.w/2,b.y+b.h/2);
1112
1159
  var p2=edgePt(b,a.x+a.w/2,a.y+a.h/2);
1113
1160
  var dx=p2.x-p1.x,dy=p2.y-p1.y,d=Math.sqrt(dx*dx+dy*dy)||1;
@@ -1118,13 +1165,19 @@ function erDiagramSection(data) {
1118
1165
  ctx.setLineDash(dashed?[5,4]:[]);
1119
1166
  ctx.beginPath();ctx.moveTo(q1x,q1y);ctx.lineTo(q2x,q2y);ctx.stroke();
1120
1167
  ctx.setLineDash([]);
1121
- pushBadge(q1x,q1y,ux,uy,manyA,col);
1122
- pushBadge(q2x,q2y,-ux,-uy,manyB,col);
1168
+ pushBadge(q1x,q1y,ux,uy,carA,col);
1169
+ pushBadge(q2x,q2y,-ux,-uy,carB,col);
1123
1170
  if(label){
1124
1171
  var mx=(q1x+q2x)/2,my=(q1y+q2y)/2;
1172
+ var la=Math.atan2(uy,ux);
1173
+ if(la>Math.PI/2||la<-Math.PI/2)la+=Math.PI; // keep text readable
1174
+ var ls=off>0?11:-11; // alternate above/below for parallel edges
1175
+ ctx.save();
1176
+ ctx.translate(mx,my);
1177
+ ctx.rotate(la);
1125
1178
  ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='center';ctx.textBaseline='middle';
1126
- ctx.fillText(label,mx-uy*14,my+ux*14);
1127
- ctx.textBaseline='alphabetic';
1179
+ ctx.fillText(label,0,ls);
1180
+ ctx.restore();
1128
1181
  }
1129
1182
  }
1130
1183
 
@@ -1140,14 +1193,14 @@ function erDiagramSection(data) {
1140
1193
  var col=ecol(r.type);
1141
1194
  if(r.type==='many2many'&&r.through&&nIdx[r.through]!=null){
1142
1195
  var sn=nodes[nIdx[r.source]],pn=nodes[nIdx[r.through]],tn=nodes[nIdx[r.target]];
1143
- if(sn&&pn)edges.push({a:sn,b:pn,col:col,dashed:true,label:r.foreignKey||'',manyA:false,manyB:true,off:0});
1144
- if(tn&&pn)edges.push({a:tn,b:pn,col:col,dashed:true,label:r.otherKey||'',manyA:false,manyB:true,off:0});
1196
+ if(sn&&pn)edges.push({a:sn,b:pn,col:col,dashed:true,label:r.foreignKey||'',carA:r.fkCarDirect||'1..1',carB:r.fkCarInverse||'0..n',off:0});
1197
+ if(tn&&pn)edges.push({a:tn,b:pn,col:col,dashed:true,label:r.otherKey||'',carA:r.otherCarDirect||'1..1',carB:r.otherCarInverse||'0..n',off:0});
1145
1198
  }else{
1146
1199
  var sn=nodes[nIdx[r.source]],tn=nodes[nIdx[r.target]];
1147
1200
  if(sn&&tn&&sn!==tn){
1148
- var manyA=r.carInverse?r.carInverse.endsWith('n'):r.type!=='one2one';
1149
- var manyB=r.carDirect?r.carDirect.endsWith('n'):r.type==='many2many';
1150
- edges.push({a:sn,b:tn,col:col,dashed:false,label:r.label,manyA:manyA,manyB:manyB,off:0});
1201
+ var carA=r.carInverse||(r.type!=='one2one'?'1..n':'1..1');
1202
+ var carB=r.carDirect||(r.type==='many2many'?'1..n':'1..1');
1203
+ edges.push({a:sn,b:tn,col:col,dashed:false,label:r.label,carA:carA,carB:carB,off:0});
1151
1204
  }
1152
1205
  }
1153
1206
  });
@@ -1206,12 +1259,24 @@ function erDiagramSection(data) {
1206
1259
 
1207
1260
  function drawLegend(){
1208
1261
  var cvW=cv.width/dpr,cvH=cv.height/dpr;
1209
- var y0=cvH-LGH,y1=cvH-LGH+18,y2=cvH-LGH+38;
1262
+ var yL=cvH-LGH,y1=yL+13,y2=yL+35,y3=yL+62;
1210
1263
 
1211
1264
  ctx.fillStyle='#161b22';
1212
- ctx.fillRect(0,y0,cvW,LGH);
1265
+ ctx.fillRect(0,yL,cvW,LGH);
1213
1266
  ctx.strokeStyle='#30363d';ctx.lineWidth=1;
1214
- ctx.beginPath();ctx.moveTo(0,y0);ctx.lineTo(cvW,y0);ctx.stroke();
1267
+ ctx.beginPath();ctx.moveTo(0,yL);ctx.lineTo(cvW,yL);ctx.stroke();
1268
+
1269
+ function legendBadge(cx,cy,car,col,angle){
1270
+ var p=P2D[car]||P2D['n'],sc=8/40;
1271
+ ctx.save();
1272
+ ctx.translate(cx,cy);
1273
+ ctx.rotate(angle);
1274
+ ctx.scale(sc,sc);
1275
+ ctx.translate(-40,-40);
1276
+ ctx.fillStyle=col;
1277
+ ctx.fill(p,'evenodd');
1278
+ ctx.restore();
1279
+ }
1215
1280
 
1216
1281
  // Row 1: relation line types
1217
1282
  var types=[
@@ -1230,42 +1295,43 @@ function erDiagramSection(data) {
1230
1295
  x+=18+ctx.measureText(it.l).width+22;
1231
1296
  });
1232
1297
 
1233
- // Row 2: cardinality symbols
1234
- function badge2(cx,cy,many,col){
1235
- ctx.fillStyle=col;ctx.strokeStyle=col;ctx.lineWidth=1;
1236
- ctx.beginPath();ctx.arc(cx,cy,6,0,Math.PI*2);ctx.fill();
1237
- ctx.save();ctx.translate(cx,cy);
1238
- ctx.strokeStyle=C.bg;ctx.lineWidth=1.2;ctx.lineCap='round';
1239
- ctx.beginPath();
1240
- if(many){
1241
- ctx.moveTo(-4,-3);ctx.lineTo(4,0);ctx.moveTo(-4,3);ctx.lineTo(4,0);
1242
- ctx.moveTo(-4,0);ctx.lineTo(4,0);ctx.moveTo(-4,-3);ctx.lineTo(-4,3);
1243
- }else{
1244
- ctx.moveTo(-2,-3);ctx.lineTo(-2,3);ctx.moveTo(2,-3);ctx.lineTo(2,3);
1245
- }
1246
- ctx.stroke();ctx.restore();
1247
- }
1248
-
1249
- ctx.font='10px monospace';ctx.textAlign='left';ctx.textBaseline='middle';
1298
+ // Row 2: relation type examples (pair of badges + line)
1250
1299
  x=14;
1251
1300
  var pairs=[
1252
- {a:false,b:true,col:C.m2o,l:'many2one (N:1)'},
1253
- {a:false,b:false,col:C.o2o,l:'one2one (1:1)'},
1254
- {a:false,b:true,col:C.m2m,l:'pivot edge (1:N)'}
1301
+ {carA:'1..n',carB:'1..1',col:C.m2o,l:'many2one (N:1)'},
1302
+ {carA:'1..1',carB:'1..1',col:C.o2o,l:'one2one (1:1)'},
1303
+ {carA:'1..1',carB:'0..n',col:C.m2m,l:'pivot edge (1:0..N)'}
1255
1304
  ];
1256
1305
  pairs.forEach(function(p){
1257
1306
  ctx.strokeStyle=p.col+'55';ctx.lineWidth=1.2;ctx.setLineDash(p.col===C.m2m?[4,3]:[]);
1258
1307
  ctx.beginPath();ctx.moveTo(x+8,y2);ctx.lineTo(x+56,y2);ctx.stroke();
1259
1308
  ctx.setLineDash([]);
1260
- badge2(x+8,y2,p.a,p.col);
1261
- badge2(x+56,y2,p.b,p.col);
1309
+ legendBadge(x+8,y2,p.carA,p.col,0);
1310
+ legendBadge(x+56,y2,p.carB,p.col,Math.PI);
1262
1311
  ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='left';ctx.textBaseline='middle';
1263
1312
  ctx.fillText(p.l,x+66,y2);
1264
1313
  x+=66+ctx.measureText(p.l).width+24;
1265
1314
  });
1266
1315
 
1316
+ // Row 3: individual symbol meanings
1317
+ var symbols=[
1318
+ {car:'1..1',l:'exactly one'},
1319
+ {car:'0..1',l:'zero or one'},
1320
+ {car:'1..n',l:'one or more'},
1321
+ {car:'0..n',l:'zero or more'},
1322
+ {car:'n', l:'many (pivot)'}
1323
+ ];
1324
+ x=14;
1325
+ ctx.font='10px monospace';ctx.textAlign='left';ctx.textBaseline='middle';
1326
+ symbols.forEach(function(s){
1327
+ legendBadge(x+8,y3,s.car,C.mu,0);
1328
+ ctx.fillStyle=C.mu;
1329
+ ctx.fillText(s.l,x+20,y3);
1330
+ x+=20+ctx.measureText(s.l).width+18;
1331
+ });
1332
+
1267
1333
  ctx.fillStyle='#3d444d';ctx.font='10px monospace';ctx.textAlign='right';ctx.textBaseline='middle';
1268
- ctx.fillText('drag \xB7 scroll to zoom \xB7 dbl-click to reset',cvW-14,y2);
1334
+ ctx.fillText('drag \xB7 scroll to zoom \xB7 dbl-click to reset',cvW-14,y1);
1269
1335
  ctx.textBaseline='alphabetic';
1270
1336
  }
1271
1337
 
@@ -1277,7 +1343,7 @@ function erDiagramSection(data) {
1277
1343
  ctx.save();ctx.translate(pan.x,pan.y);ctx.scale(zm,zm);
1278
1344
  badges=[];
1279
1345
  var edges=collectEdges();
1280
- edges.forEach(function(e){drawEdgeLine(e.a,e.b,e.col,e.dashed,e.label,e.manyA,e.manyB,e.off);});
1346
+ edges.forEach(function(e){drawEdgeLine(e.a,e.b,e.col,e.dashed,e.label,e.carA,e.carB,e.off);});
1281
1347
  nodes.forEach(function(n){drawNode(n);});
1282
1348
  badges.forEach(function(b){drawBadge(b);});
1283
1349
  ctx.restore();
@@ -1323,14 +1389,21 @@ function erDiagramSection(data) {
1323
1389
  });
1324
1390
 
1325
1391
  document.getElementById('er-dl').addEventListener('click',function(){
1326
- var SCALE=3;
1327
- var cw=cv.width/dpr,ch=cv.height/dpr;
1392
+ var SCALE=parseInt(document.getElementById('er-scale').value,10)||3;
1393
+ var pad=36;
1394
+ var bx0=1e9,by0=1e9,bx1=-1e9,by1=-1e9;
1395
+ nodes.forEach(function(n){
1396
+ bx0=Math.min(bx0,n.x);by0=Math.min(by0,n.y);
1397
+ bx1=Math.max(bx1,n.x+n.w);by1=Math.max(by1,n.y+n.h);
1398
+ });
1399
+ var cw=bx1-bx0+pad*2,ch=by1-by0+pad*2+LGH;
1328
1400
  var oc=document.createElement('canvas');
1329
1401
  oc.width=Math.round(cw*SCALE);oc.height=Math.round(ch*SCALE);
1330
- var sav={cv:cv,ctx:ctx,dpr:dpr};
1402
+ var sav={cv:cv,ctx:ctx,dpr:dpr,px:pan.x,py:pan.y,zm:zm};
1331
1403
  cv=oc;ctx=oc.getContext('2d');dpr=SCALE;
1404
+ pan.x=pad-bx0;pan.y=pad-by0;zm=1;
1332
1405
  draw();
1333
- cv=sav.cv;ctx=sav.ctx;dpr=sav.dpr;
1406
+ cv=sav.cv;ctx=sav.ctx;dpr=sav.dpr;pan.x=sav.px;pan.y=sav.py;zm=sav.zm;
1334
1407
  var a=document.createElement('a');
1335
1408
  a.download='er-diagram.png';a.href=oc.toDataURL('image/png');a.click();
1336
1409
  });
@@ -1338,6 +1411,7 @@ function erDiagramSection(data) {
1338
1411
  // \u2500\u2500 Init \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1339
1412
  requestAnimationFrame(function(){
1340
1413
  resize();
1414
+ computeNodeWidths();
1341
1415
  var r=cv.getBoundingClientRect();
1342
1416
  computeLayout(r.width,r.height);
1343
1417
  var h=requiredH();