태그 선택 — 타입별로 자주 쓰는 것을 그 자리에 깐다
태그를 하나 걸려면 매번 모달을 열어 500여 개(액션 160여 개) 목록에서 찾아야 했다. 한 서식이 쓰는 서로 다른 태그는 중앙값 6개, 최대 27개다(--db-tags) — 서식 하나 만들 때마다 그 짓을 예닐곱 번 한다. ■ 실측이 설계를 정했다 --db-tags 진단을 신설해 운영 디자인 1,271건의 태그 사용 8,032건을 컨트롤 타입별로 집계했다. 후보가 타입마다 거의 완전히 갈린다: 이미지 · 데이터 태그 343건 / 7종 — 상위 16종이 100% (서명·직인·로고가 전부) 라디오 · 액션 태그 736건 / 7종 — 100% (RadCheckControl 이 580건) 체크박스 · 액션 태그 357건 / 7종 — 100% 텍스트박스 · 액션 태그 200건 / 6종 — 100% 마스크입력 · 데이터 태그 57건 / 13종 — 100% 버튼 · 액션 태그 393건 / 46종 — 상위 16종이 83% 텍스트박스 · 데이터 태그 5,927건 / 144종 — 상위 16종이 65% (여기만 꼬리가 길다) 즉 일곱 조합 중 다섯은 **짧은 목록이 운영 전체를 덮는다**. 이미지 하나 고르려고 500개를 뒤지고 있었다는 뜻이다. ■ 바꾼 것 · 인스펙터 행 아래에 그 타입에서 자주 쓰는 태그를 빈도순 칩으로 깐다(최대 8개). 누르면 바로 값이 된다. 칩은 **값이 비어 있을 때만** 뜬다 — 이미 정해진 행 아래에도 깔면 좁은 패널이 금방 길어진다. · 피커를 열면 자주 쓰는 것이 맨 위로 올라온다(그 안에서는 빈도순, 나머지는 원래 가나다순 유지). 건수 표시에 '자주 쓰는 것 N건이 위에' 를 덧붙였다. · 전체 카탈로그와 자유 입력은 그대로다. 제안은 제안이지 제한이 아니다 — 타입별 자료가 없는 조합의 액션 태그는 아예 비워 둔다. 추측으로 권하면 그 칸은 배포된 뒤에야 빈칸으로 드러난다. 순서를 가나다순이 아니라 빈도순으로 둔 것이 핵심이다. 가나다순은 운영의 절반을 차지하는 RadCheckControl 과 한 번도 안 쓰인 태그를 같은 무게로 보여 준다. ■ 곁다리로 확인한 것 카탈로그에 없는 값이 데이터 태그 1종(PAT_전체주소 ×11), 액션 태그 1종(OprEmrList ×5) 나왔다. 둘 다 레거시 bzDataInterface / EN_DataActionTyp 에 존재하지 않는다 — 우리 카탈로그가 맞고, 저 값들은 운영 서식에 이미 박힌 죽은 태그다(런타임이 해석하지 못한다). 우리가 만든 것이 아니라 손대지 않았다. 편집 스모크 17건 추가(타입별 좁힘 / 빈도 순서 / 자료 없는 타입은 빈 목록 / 제안이 전부 카탈로그에 있는지 12조합 검사 / 값이 정해지면 칩 감춤). 회귀: 테스트 142/142, 편집 스모크 210건 실패 0, DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ddf2bdaee8
commit
de01012b7e
@@ -0,0 +1,113 @@
|
||||
namespace SheetMe.Core.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// 태그 실사용 순위 — 컨트롤 타입별로 '실제로 쓰이는 태그'를 많이 쓰는 순서로 준다.
|
||||
///
|
||||
/// <b>왜 타입별인가.</b> 카탈로그는 데이터 태그 500여 종 · 액션 태그 160여 종인데,
|
||||
/// 어느 타입에서든 똑같은 전체 목록을 보여 주면 원하는 것을 찾는 데 시간이 걸린다.
|
||||
/// 실측(진단 <c>--db-tags</c>, 디자인 1,271건 / 태그 사용 8,032건)은 후보가 타입마다
|
||||
/// 거의 완전히 갈린다는 것을 보여 준다:
|
||||
/// <list type="bullet">
|
||||
/// <item>이미지(PictureBox)의 데이터 태그는 운영 전체에서 <b>7종이 전부</b>다 — 서명과 직인·로고.</item>
|
||||
/// <item>라디오·체크박스의 액션 태그도 각각 7종으로 100% 를 덮는다.</item>
|
||||
/// <item>텍스트박스만 꼬리가 길다(144종). 그래도 상위 16종이 65% 다.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <b>이 목록은 제안이지 제한이 아니다.</b> 전체 카탈로그는 피커에 그대로 남아 있고
|
||||
/// 자유 입력도 열려 있다. 여기 없는 태그를 쓰는 것은 언제나 가능하다.
|
||||
///
|
||||
/// 순서는 알파벳순이 아니라 <b>사용 빈도순</b>이다. 가나다순으로 늘어놓으면
|
||||
/// 가장 흔한 PAT_이름 과 거의 안 쓰는 태그가 같은 무게로 보인다.
|
||||
/// </summary>
|
||||
public static class LegacyTagUsageCatalog
|
||||
{
|
||||
#region Member Fields
|
||||
// 키: "{속성키}|{중립 타입명}". 값: 사용 빈도 내림차순.
|
||||
private static readonly Dictionary<string, string[]> byKeyAndType = new(StringComparer.Ordinal)
|
||||
{
|
||||
// ── 데이터 태그(자동 채움 원천) ──
|
||||
["DataInterfaceTag|TextBox"] = new[]
|
||||
{
|
||||
"PAT_이름", "PAT_차트번호", "ETC_요양기관명칭_병원명", "PAT_성별", "ETC_현재일자",
|
||||
"OCM_진단서의사명", "PAT_생년월일", "PAT_나이", "OCM_진료과", "PAT_주민번호_Dash",
|
||||
"ETC_로그인_사용자명", "PAT_휴대전화번호", "OCM_진단서의사면허번호", "ETC_요양기관기호",
|
||||
"OCM_담당의사", "PAT_도로명주소",
|
||||
},
|
||||
// 운영 343건이 이 7종뿐이다 — 서명란·직인·로고
|
||||
["DataInterfaceTag|PictureBox"] = new[]
|
||||
{
|
||||
"OCM_진단서의사싸인", "ETC_요양기관명칭_병원직인", "OCM_진단서의사싸인_Refer",
|
||||
"ETC_요양기관명칭_병원로고", "OCM_담당의사싸인", "OCM_로그인_사용자싸인",
|
||||
},
|
||||
// 마스크 입력은 날짜·시각이 사실상 전부다
|
||||
["DataInterfaceTag|MaskedTextBox"] = new[]
|
||||
{
|
||||
"ETC_현재일자", "ETC_현재일시_MaskBox", "OCM_입원내원일자", "OCM_퇴원일시",
|
||||
"OCM_외래내원일자", "PAT_주민번호", "ETC_현재일시", "OCM_입원일시",
|
||||
},
|
||||
|
||||
// ── 액션 태그(더블클릭/버튼 동작) ──
|
||||
// 라디오·체크박스는 각각 7종으로 운영 100% 를 덮는다
|
||||
["DataActionTag|RadioButton"] = new[]
|
||||
{
|
||||
"RadCheckControl", "ChoiceData", "PanelVisible", "UnAllCheck",
|
||||
"PanelUnVisible", "ButtonData", "SetVisibleStatus",
|
||||
},
|
||||
["DataActionTag|CheckBox"] = new[]
|
||||
{
|
||||
"RadCheckControl", "VisibleYon", "DataCopy_Chk", "MinusToPlus",
|
||||
"AllCheck", "ChoiceData", "DateVisibleYon",
|
||||
},
|
||||
["DataActionTag|TextBox"] = new[]
|
||||
{
|
||||
"DtrLicList", "UmlsMst", "ReturnUidNam", "YondoList", "OkdInfList",
|
||||
},
|
||||
// 버튼은 검색 계열이라 꼬리가 있다(46종) — 상위 16종이 83%
|
||||
["DataActionTag|Button"] = new[]
|
||||
{
|
||||
"ComOkdList", "ImgeList", "CodeFInd", "OkdInfList", "OprInfList",
|
||||
"OkdInfData", "EmrSelectData", "YondoList", "TPRList", "ComPidList",
|
||||
"TPR", "AllCheck", "ReturnLabData", "EmgTotalEmr", "DataCopy_MtM", "DtrLicList",
|
||||
},
|
||||
};
|
||||
|
||||
// 타입 구분 없이 흔한 데이터 태그 — 위 표에 타입이 없을 때의 폴백(예: 콤보·날짜선택)
|
||||
private static readonly string[] CommonDataTags =
|
||||
{
|
||||
"PAT_이름", "PAT_차트번호", "PAT_성별", "PAT_생년월일", "PAT_나이",
|
||||
"ETC_현재일자", "OCM_진료과", "OCM_담당의사",
|
||||
};
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>
|
||||
/// 이 컨트롤 타입에서 자주 쓰이는 태그 — 빈도 내림차순.
|
||||
/// 타입별 자료가 없으면 데이터 태그는 공통 목록, 액션 태그는 빈 목록(추측하지 않는다).
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> SuggestFor(string propertyKey, string controlType)
|
||||
{
|
||||
if (byKeyAndType.TryGetValue(propertyKey + "|" + controlType, out var ranked))
|
||||
{
|
||||
return ranked;
|
||||
}
|
||||
return propertyKey == "DataInterfaceTag" ? CommonDataTags : Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 피커 목록 정렬용 순위 — 낮을수록 위. 실사용 자료에 없으면 int.MaxValue.
|
||||
/// 가나다순으로만 늘어놓으면 가장 흔한 태그와 거의 안 쓰는 태그가 같은 무게로 보인다.
|
||||
/// </summary>
|
||||
public static int RankOf(string propertyKey, string controlType, string tag)
|
||||
{
|
||||
var ranked = SuggestFor(propertyKey, controlType);
|
||||
for (var i = 0; i < ranked.Count; i++)
|
||||
{
|
||||
if (string.Equals(ranked[i], tag, StringComparison.Ordinal))
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return int.MaxValue;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -142,6 +142,11 @@ public partial class App : Application
|
||||
return Diagnostics.DbSmoke.RunRow(args[1], args[2]);
|
||||
}
|
||||
|
||||
if (args.Length >= 2 && args[0] == "--db-tags")
|
||||
{
|
||||
return Diagnostics.DbSmoke.RunTagUsageReport(args[1]);
|
||||
}
|
||||
|
||||
if (args.Length >= 2 && args[0] == "--db-phase3")
|
||||
{
|
||||
return Diagnostics.DbSmoke.RunPhase3Report(args[1]);
|
||||
|
||||
@@ -615,6 +615,148 @@ public static class DbSmoke
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 데이터·액션 태그 실사용 분포(--db-tags) — 태그 피커를 어떻게 정렬·큐레이션할지 근거(읽기 전용).
|
||||
///
|
||||
/// 카탈로그는 수백 종인데 목록이 가나다순이면 자주 쓰는 태그가 어디 있는지 알 수 없다.
|
||||
/// 상위 몇 개가 전체의 몇 %를 덮는지 알아야 '자주 쓰는 것 먼저' 가 의미 있는지 판단할 수 있다.
|
||||
/// 타입별로도 나눈다 — 이미지에 붙는 태그와 텍스트박스에 붙는 태그는 겹치지 않는다.
|
||||
/// </summary>
|
||||
public static int RunTagUsageReport(string reportPath)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
try
|
||||
{
|
||||
var config = ConfigService.Current;
|
||||
var store = new OracleLegacyFormStore(config.ConnectionString);
|
||||
var serializer = new LegacyXmlSerializer();
|
||||
var sheets = store.ListSheets(null, max: 3000).Where(s => s.HasDesign).ToList();
|
||||
|
||||
var byKey = new Dictionary<string, Dictionary<string, int>>(StringComparer.Ordinal);
|
||||
// 태그 → 이 태그를 쓰는 컨트롤 타입 분포
|
||||
var byType = new Dictionary<string, Dictionary<string, int>>(StringComparer.Ordinal);
|
||||
// 한 서식이 쓰는 서로 다른 태그 수 — 피커를 몇 번 여는지의 대리 지표
|
||||
var perSheet = new List<int>();
|
||||
|
||||
foreach (var sheet in sheets)
|
||||
{
|
||||
var sheetTags = new HashSet<string>(StringComparer.Ordinal);
|
||||
try
|
||||
{
|
||||
var raw = store.LoadActiveDesignRaw(sheet.ShtCod);
|
||||
if (raw is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
void Visit(Core.Models.ControlElement element)
|
||||
{
|
||||
foreach (var key in new[] { "DataInterfaceTag", "DataActionTag" })
|
||||
{
|
||||
var value = element.Props.GetText(key);
|
||||
// "None" 은 액션 없음의 기본값이라 사용으로 치지 않는다
|
||||
if (value is null || value.Length == 0 || value == "None")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!byKey.TryGetValue(key, out var bucket))
|
||||
{
|
||||
bucket = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
byKey[key] = bucket;
|
||||
}
|
||||
bucket[value] = bucket.GetValueOrDefault(value) + 1;
|
||||
sheetTags.Add(key + "/" + value);
|
||||
|
||||
var typeKey = key + "|" + element.Type;
|
||||
if (!byType.TryGetValue(typeKey, out var types))
|
||||
{
|
||||
types = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
byType[typeKey] = types;
|
||||
}
|
||||
types[value] = types.GetValueOrDefault(value) + 1;
|
||||
}
|
||||
foreach (var child in element.Children)
|
||||
{
|
||||
Visit(child);
|
||||
}
|
||||
}
|
||||
foreach (var page in serializer.Read(raw.Value.Xml).Pages)
|
||||
{
|
||||
Visit(page.Root);
|
||||
}
|
||||
if (sheetTags.Count > 0)
|
||||
{
|
||||
perSheet.Add(sheetTags.Count);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 통계용
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (key, bucket) in byKey.OrderBy(p => p.Key, StringComparer.Ordinal))
|
||||
{
|
||||
var total = bucket.Values.Sum();
|
||||
var ranked = bucket.OrderByDescending(p => p.Value).ToList();
|
||||
lines.Add(string.Empty);
|
||||
lines.Add($"[{key}] 사용 {total}건 / 서로 다른 값 {bucket.Count}종");
|
||||
foreach (var n in new[] { 5, 10, 20, 30, 50 })
|
||||
{
|
||||
var covered = ranked.Take(n).Sum(p => p.Value);
|
||||
lines.Add($" 상위 {n,2}종이 전체의 {100.0 * covered / Math.Max(1, total),5:F1}% 를 덮는다");
|
||||
}
|
||||
lines.Add(" ── 상위 30 ──");
|
||||
var rank = 0;
|
||||
foreach (var (value, count) in ranked.Take(30))
|
||||
{
|
||||
rank++;
|
||||
lines.Add($" {rank,2}. {value,-34} {count,6}건 ({100.0 * count / total,4:F1}%)");
|
||||
}
|
||||
// 카탈로그에 없는 값 = 사이트가 추가했거나 오타
|
||||
var catalog = key == "DataInterfaceTag"
|
||||
? (IReadOnlyList<string>)Core.Catalog.LegacyTagCatalog.DataInterfaceTags
|
||||
: Core.Catalog.LegacyTagCatalog.DataActionTags;
|
||||
var unknown = ranked.Where(p => !catalog.Contains(p.Key)).ToList();
|
||||
lines.Add($" 카탈로그에 없는 값: {unknown.Count}종 / {unknown.Sum(p => p.Value)}건");
|
||||
lines.AddRange(unknown.Take(10).Select(p => $" {p.Key} ×{p.Value}"));
|
||||
}
|
||||
|
||||
if (perSheet.Count > 0)
|
||||
{
|
||||
perSheet.Sort();
|
||||
lines.Add(string.Empty);
|
||||
lines.Add($"태그를 쓰는 서식 {perSheet.Count}건 — 한 서식이 쓰는 서로 다른 태그 수: " +
|
||||
$"중앙값 {perSheet[perSheet.Count / 2]}, 최대 {perSheet[^1]}, 평균 {perSheet.Average():F1}");
|
||||
}
|
||||
|
||||
lines.Add(string.Empty);
|
||||
lines.Add("[컨트롤 타입별 상위 태그] — 타입을 알면 후보를 좁힐 수 있는지 확인용");
|
||||
foreach (var (typeKey, values) in byType
|
||||
.Where(p => p.Value.Values.Sum() >= 20)
|
||||
.OrderByDescending(p => p.Value.Values.Sum()))
|
||||
{
|
||||
var ranked = values.OrderByDescending(p => p.Value).ToList();
|
||||
var sum = values.Values.Sum();
|
||||
var top16 = ranked.Take(16).Sum(p => p.Value);
|
||||
lines.Add(string.Empty);
|
||||
lines.Add($" {typeKey} {sum}건 / {ranked.Count}종 — 상위 16종이 {100.0 * top16 / sum:F1}%");
|
||||
foreach (var (value, count) in ranked.Take(16))
|
||||
{
|
||||
lines.Add($" {value,-34} {count,5}");
|
||||
}
|
||||
}
|
||||
|
||||
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lines.Add("실패: " + ex);
|
||||
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 3단계 판단용 실측(--db-phase3) — 목록·정렬 관련 컬럼이 실제로 채워져 있는지(읽기 전용).
|
||||
///
|
||||
|
||||
@@ -129,6 +129,8 @@ public static class DialogShots
|
||||
("10-inspector-textbox", () => HostInspector(designer, "TextBox1")),
|
||||
("11-inspector-data", () => HostInspector(designer, "TextBox1", InspectorTab.Data)),
|
||||
("12-inspector-behavior", () => HostInspector(designer, "TextBox1", InspectorTab.Behavior)),
|
||||
// 태그 제안이 타입별로 좁혀지는지 — 이미지는 서명·직인 6종만 떠야 한다
|
||||
("12b-inspector-picture-data", () => HostInspector(designer, "MPictureBox1", InspectorTab.Data)),
|
||||
("13-layer-type-filter", () => new Views.LayerTypeFilterDialog(
|
||||
designer.LayerTypeCandidates(), new[] { "Label" })),
|
||||
// 레이어 아웃라인 — 선택 없음 / 컨트롤 하나 선택 / 그룹 전체 선택 세 상태를 각각 찍는다.
|
||||
@@ -314,6 +316,8 @@ public static class DialogShots
|
||||
page.Controls.Add(NewControl("Label", "Label2", "생년월일", 40, 80, 120, 24));
|
||||
// 이름만 있고 문구가 없는 컨트롤 — 레이어 목록에서 이름 칸이 중복 표시되지 않는지 확인용
|
||||
page.Controls.Add(NewControl("TextBox", "TextBox2", string.Empty, 170, 80, 200, 26));
|
||||
// 이미지는 데이터 태그 후보가 운영 전체에서 7종뿐이다 — 타입별 제안이 실제로 좁아지는지 확인용
|
||||
page.Controls.Add(NewControl("PictureBox", "MPictureBox1", string.Empty, 400, 40, 120, 90));
|
||||
document.Pages.Add(page);
|
||||
return new DesignerViewModel(document);
|
||||
}
|
||||
|
||||
@@ -886,6 +886,49 @@ public static class EditSmoke
|
||||
ViewModels.MainViewModel.FindOpenSheet(new[] { Tab(string.Empty) }, string.Empty) is null);
|
||||
}
|
||||
|
||||
// 20-5i) 태그 제안 — 후보는 컨트롤 타입마다 거의 완전히 갈린다.
|
||||
// 이미지의 데이터 태그는 운영 전체가 6종(서명·직인·로고)이라 500여 개 목록을 뒤질 이유가 없다.
|
||||
{
|
||||
var pictureSuggest = SheetMe.Core.Catalog.LegacyTagUsageCatalog
|
||||
.SuggestFor("DataInterfaceTag", "PictureBox");
|
||||
Check("태그 제안: 이미지는 서명·직인으로 좁혀진다",
|
||||
pictureSuggest.Count is > 0 and <= 8
|
||||
&& pictureSuggest[0] == "OCM_진단서의사싸인"
|
||||
&& pictureSuggest.All(t => t.Contains("싸인") || t.Contains("직인") || t.Contains("로고")),
|
||||
string.Join(",", pictureSuggest));
|
||||
|
||||
var textSuggest = SheetMe.Core.Catalog.LegacyTagUsageCatalog
|
||||
.SuggestFor("DataInterfaceTag", "TextBox");
|
||||
Check("태그 제안: 텍스트박스는 환자 기본정보가 앞에", textSuggest[0] == "PAT_이름", textSuggest[0]);
|
||||
Check("태그 제안: 타입마다 후보가 다르다", !textSuggest.SequenceEqual(pictureSuggest));
|
||||
|
||||
// 액션 태그는 타입 자료가 없으면 추측하지 않는다 — 잘못 권하면 런타임이 조용히 어긋난다
|
||||
Check("태그 제안: 자료 없는 타입의 액션 태그는 빈 목록",
|
||||
SheetMe.Core.Catalog.LegacyTagUsageCatalog.SuggestFor("DataActionTag", "Panel").Count == 0);
|
||||
|
||||
// 제안은 전부 실제 카탈로그에 있어야 한다 — 없는 태그를 권하면 그 칸은 배포 후 빈칸이 된다
|
||||
foreach (var (key, catalog) in new[]
|
||||
{
|
||||
("DataInterfaceTag", (IReadOnlyList<string>)SheetMe.Core.Catalog.LegacyTagCatalog.DataInterfaceTags),
|
||||
("DataActionTag", SheetMe.Core.Catalog.LegacyTagCatalog.DataActionTags),
|
||||
})
|
||||
{
|
||||
foreach (var type in new[] { "TextBox", "PictureBox", "MaskedTextBox", "CheckBox", "RadioButton", "Button" })
|
||||
{
|
||||
var bad = SheetMe.Core.Catalog.LegacyTagUsageCatalog.SuggestFor(key, type)
|
||||
.Where(t => !catalog.Contains(t)).ToList();
|
||||
Check($"태그 제안: {key}|{type} 은 전부 카탈로그에 있다", bad.Count == 0, string.Join(",", bad));
|
||||
}
|
||||
}
|
||||
|
||||
// 값이 정해지면 칩을 감춘다 — 좁은 인스펙터가 길어지지 않도록
|
||||
var tagRow = new ViewModels.Inspector.TagPickerRowViewModel("데이터 태그", "제목",
|
||||
SheetMe.Core.Catalog.LegacyTagCatalog.DataInterfaceTags, pictureSuggest);
|
||||
Check("태그 제안: 값이 비면 칩을 보여 준다", tagRow.ShowSuggestions);
|
||||
tagRow.ValueText = "PAT_이름";
|
||||
Check("태그 제안: 값이 정해지면 칩을 감춘다", !tagRow.ShowSuggestions);
|
||||
}
|
||||
|
||||
// 20-5d) 태그 카탈로그 — 레거시에서 주석 처리된 태그를 고를 수 있으면 안 된다
|
||||
Check("태그 카탈로그: 미구현 3종 제외",
|
||||
!SheetMe.Core.Catalog.LegacyTagCatalog.DataInterfaceTags.Contains("PAT_BMI")
|
||||
|
||||
@@ -237,7 +237,7 @@ public sealed class InspectorViewModel : ViewModelBase
|
||||
AddSection(descriptor.DisplayName);
|
||||
foreach (var def in tabDefs)
|
||||
{
|
||||
AddDefRow(def);
|
||||
AddDefRow(def, descriptor.Type);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -543,7 +543,7 @@ public sealed class InspectorViewModel : ViewModelBase
|
||||
}, LegacyPropertyCatalog.SuggestFor(target.Type, target.Model.Props.Keys)));
|
||||
}
|
||||
|
||||
private void AddDefRow(PropertyDef def)
|
||||
private void AddDefRow(PropertyDef def, string controlType)
|
||||
{
|
||||
var binding = def.Editor == PropEditorKind.StringList
|
||||
? new RowBinding
|
||||
@@ -596,10 +596,13 @@ public sealed class InspectorViewModel : ViewModelBase
|
||||
=> new AlignRowViewModel(def.Label, def.Choices ?? Array.Empty<string>()),
|
||||
PropEditorKind.Choice => new ChoiceRowViewModel(def.Label, def.Choices ?? Array.Empty<string>()),
|
||||
PropEditorKind.Color => new ColorRowViewModel(def.Label),
|
||||
// 제안은 '이 컨트롤 타입에서 실제로 쓰이는 것'으로 좁힌다 — 후보가 타입마다 거의 완전히 갈린다
|
||||
PropEditorKind.DataInterfaceTag => new TagPickerRowViewModel(def.Label,
|
||||
"데이터 태그 선택 — 자동 채움 원천(bzDataInterface)", LegacyTagCatalog.DataInterfaceTags),
|
||||
"데이터 태그 선택 — 자동 채움 원천(bzDataInterface)", LegacyTagCatalog.DataInterfaceTags,
|
||||
LegacyTagUsageCatalog.SuggestFor(def.Key, controlType)),
|
||||
PropEditorKind.DataActionTag => new TagPickerRowViewModel(def.Label,
|
||||
"액션 태그 선택 — 더블클릭/버튼 액션(EN_DataActionTyp)", LegacyTagCatalog.DataActionTags),
|
||||
"액션 태그 선택 — 더블클릭/버튼 액션(EN_DataActionTyp)", LegacyTagCatalog.DataActionTags,
|
||||
LegacyTagUsageCatalog.SuggestFor(def.Key, controlType)),
|
||||
PropEditorKind.SqlQuery => new QueryRowViewModel(def.Label),
|
||||
PropEditorKind.Mask => new MaskRowViewModel(def.Label),
|
||||
// 배선 대상 목록은 '지금 이 서식'의 데이터소스다 — 런타임도 같은 서식 안에서만 이름을 찾는다
|
||||
|
||||
@@ -416,19 +416,63 @@ public sealed class TagPickerRowViewModel : PropertyRowViewModel
|
||||
/// <summary>피커 대화상자 제목</summary>
|
||||
public string PickerTitle { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 한 줄에 하나꼴로 접히는 긴 한글 태그를 몇 개까지 깔지.
|
||||
/// 인스펙터 폭이 좁아 칩이 세로로 쌓인다 — 8개를 넘기면 다른 속성이 화면 밖으로 밀린다.
|
||||
/// 나머지는 피커에 있고, 거기서도 자주 쓰는 것이 위로 온다.
|
||||
/// </summary>
|
||||
private const int MaxSuggestions = 8;
|
||||
|
||||
/// <summary>잘리지 않은 전체 순위 — 피커 정렬에 쓴다</summary>
|
||||
private readonly IReadOnlyList<string> preferredAll;
|
||||
|
||||
/// <summary>
|
||||
/// 이 컨트롤 타입에서 자주 쓰이는 태그 — 빈도순. 실측 근거는 LegacyTagUsageCatalog 참조.
|
||||
/// 이미지의 데이터 태그처럼 운영 전체가 7종뿐인 경우가 있어, 500여 개 목록을 뒤질 이유가 없다.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Suggestions { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 제안을 보여 줄지 — <b>값이 비어 있을 때만</b>.
|
||||
/// 이미 정해진 행 아래에도 칩을 깔면 좁은 인스펙터가 금방 길어진다.
|
||||
/// 제안이 필요한 순간은 '아직 안 골랐을 때'다.
|
||||
/// </summary>
|
||||
public bool ShowSuggestions => ValueText.Length == 0 && Suggestions.Count > 0;
|
||||
|
||||
/// <summary>찾아보기 — 검색 대화상자 열기</summary>
|
||||
public M.Framework.WPF.ICustomCommand? BrowseCommand { get; set; }
|
||||
|
||||
public TagPickerRowViewModel(string label, string pickerTitle, IReadOnlyList<string> choices) : base(label)
|
||||
/// <summary>제안 선택 — 누르면 바로 값이 된다</summary>
|
||||
public M.Framework.WPF.ICustomCommand? PickCommand { get; set; }
|
||||
|
||||
public TagPickerRowViewModel(string label, string pickerTitle, IReadOnlyList<string> choices,
|
||||
IReadOnlyList<string>? suggestions = null) : base(label)
|
||||
{
|
||||
PickerTitle = pickerTitle;
|
||||
Choices = choices;
|
||||
// 피커에는 전체 순위를 넘기고(preferredAll), 인스펙터 칩만 잘라 쓴다
|
||||
preferredAll = suggestions ?? Array.Empty<string>();
|
||||
Suggestions = preferredAll.Take(MaxSuggestions).ToList();
|
||||
BrowseCommand = new M.Framework.WPF.Command((sender, e) => OnBrowse());
|
||||
PickCommand = new M.Framework.WPF.Command((object parameter) =>
|
||||
{
|
||||
if (parameter is string picked && picked.Length > 0)
|
||||
{
|
||||
ValueText = picked;
|
||||
}
|
||||
});
|
||||
PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(ValueText))
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowSuggestions));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void OnBrowse()
|
||||
{
|
||||
var dialog = new Views.TagPickerDialogView(PickerTitle, Choices, ValueText)
|
||||
var dialog = new Views.TagPickerDialogView(PickerTitle, Choices, ValueText, preferredAll)
|
||||
{
|
||||
Owner = System.Windows.Application.Current.MainWindow,
|
||||
};
|
||||
|
||||
@@ -353,18 +353,38 @@
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:TagPickerRowViewModel}">
|
||||
<DockPanel Margin="0,2">
|
||||
<StackPanel Margin="0,2">
|
||||
<DockPanel>
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
|
||||
<!-- 26px 칩 옆에서 "…" 는 점 하나로 보인다 — 아이콘 버튼으로 -->
|
||||
<Button DockPanel.Dock="Right" Width="28" Height="26" Padding="0" Margin="4,0,0,0"
|
||||
Command="{Binding BrowseCommand}" ToolTip="목록에서 선택"
|
||||
Command="{Binding BrowseCommand}" ToolTip="전체 목록에서 선택"
|
||||
Content="{Binding Source=list, Converter={StaticResource IconName}, ConverterParameter=13}"/>
|
||||
<Grid>
|
||||
<TextBox Style="{StaticResource RowInput}" bh:InspectorFieldBehavior.CommitOnEnter="True" Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}"
|
||||
ToolTip="직접 입력하거나 오른쪽 버튼으로 목록에서 선택"/>
|
||||
ToolTip="직접 입력하거나 아래 제안을 누르세요"/>
|
||||
<TextBlock Style="{StaticResource MixedHintOverlay}"/>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
<!-- 이 컨트롤 타입에서 실제로 쓰이는 태그(빈도순). 아직 안 골랐을 때만 뜬다 —
|
||||
이미 정해진 행 아래에도 칩을 깔면 좁은 인스펙터가 금방 길어진다. -->
|
||||
<ItemsControl ItemsSource="{Binding Suggestions}" Margin="92,5,0,2"
|
||||
Visibility="{Binding ShowSuggestions, Converter={StaticResource BoolToVisibility}}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Button Style="{StaticResource SuggestChip}" Content="{Binding}"
|
||||
Command="{Binding DataContext.PickCommand,
|
||||
RelativeSource={RelativeSource AncestorType=ItemsControl}}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:ReadOnlyRowViewModel}">
|
||||
|
||||
@@ -13,15 +13,25 @@ public partial class TagPickerDialogView : Window
|
||||
private readonly IReadOnlyList<string> allTags;
|
||||
#endregion
|
||||
|
||||
#region Member Fields - Ordering
|
||||
/// <summary>
|
||||
/// 이 컨트롤 타입에서 자주 쓰이는 태그 — 목록 맨 위로 올린다.
|
||||
/// 가나다순으로만 늘어놓으면 운영 절반을 차지하는 태그와 한 번도 안 쓰인 태그가 같은 무게로 보인다.
|
||||
/// </summary>
|
||||
private readonly IReadOnlyList<string> preferred;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>선택된 태그 — '값 지우기'면 빈 문자열</summary>
|
||||
public string? SelectedTag { get; private set; }
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public TagPickerDialogView(string title, IReadOnlyList<string> tags, string? currentValue)
|
||||
public TagPickerDialogView(string title, IReadOnlyList<string> tags, string? currentValue,
|
||||
IReadOnlyList<string>? preferredTags = null)
|
||||
{
|
||||
allTags = tags;
|
||||
preferred = preferredTags ?? Array.Empty<string>();
|
||||
InitializeComponent();
|
||||
TitleText.Text = title;
|
||||
ApplyFilter(string.Empty);
|
||||
@@ -41,17 +51,43 @@ public partial class TagPickerDialogView : Window
|
||||
private void ApplyFilter(string keyword)
|
||||
{
|
||||
var tokens = keyword.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var filtered = tokens.Length == 0
|
||||
IEnumerable<string> matched = tokens.Length == 0
|
||||
? allTags
|
||||
: allTags.Where(t => tokens.All(k => t.Contains(k, StringComparison.OrdinalIgnoreCase))).ToList();
|
||||
: allTags.Where(t => tokens.All(k => t.Contains(k, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
// 자주 쓰는 것 먼저, 그 안에서는 빈도 순서 그대로. 나머지는 원래(가나다) 순서를 지킨다.
|
||||
// OrderBy 는 안정 정렬이라 같은 순위끼리는 입력 순서가 보존된다.
|
||||
var filtered = matched
|
||||
.OrderBy(t =>
|
||||
{
|
||||
var index = IndexOfPreferred(t);
|
||||
return index < 0 ? int.MaxValue : index;
|
||||
})
|
||||
.ToList();
|
||||
|
||||
TagList.ItemsSource = filtered;
|
||||
CountText.Text = $"{filtered.Count}/{allTags.Count}건";
|
||||
var hit = filtered.Count(t => IndexOfPreferred(t) >= 0);
|
||||
CountText.Text = hit > 0
|
||||
? $"{filtered.Count}/{allTags.Count}건 · 자주 쓰는 것 {hit}건이 위에"
|
||||
: $"{filtered.Count}/{allTags.Count}건";
|
||||
if (filtered.Count > 0)
|
||||
{
|
||||
TagList.SelectedIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private int IndexOfPreferred(string tag)
|
||||
{
|
||||
for (var i = 0; i < preferred.Count; i++)
|
||||
{
|
||||
if (string.Equals(preferred[i], tag, StringComparison.Ordinal))
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private void OnPick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (TagList.SelectedItem is not string tag)
|
||||
|
||||
Reference in New Issue
Block a user