From fd9bea7bd8e8ad37b977f0c12f3682d3bf1a0c70 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Tue, 30 Jun 2026 22:02:36 +0900 Subject: [PATCH] Initial sector heatmap generator --- .gitignore | 12 + mbn_stock.sln | 25 ++ mbn_stock/App.config | 6 + mbn_stock/Form1.Designer.cs | 41 +++ mbn_stock/Form1.cs | 348 +++++++++++++++++++++ mbn_stock/HeatMapRenderer.cs | 332 ++++++++++++++++++++ mbn_stock/Program.cs | 22 ++ mbn_stock/Properties/AssemblyInfo.cs | 36 +++ mbn_stock/Properties/Resources.Designer.cs | 70 +++++ mbn_stock/Properties/Resources.resx | 117 +++++++ mbn_stock/Properties/Settings.Designer.cs | 29 ++ mbn_stock/Properties/Settings.settings | 7 + mbn_stock/SectorHeatMapItem.cs | 67 ++++ mbn_stock/mbn_stock.csproj | 82 +++++ 14 files changed, 1194 insertions(+) create mode 100644 .gitignore create mode 100644 mbn_stock.sln create mode 100644 mbn_stock/App.config create mode 100644 mbn_stock/Form1.Designer.cs create mode 100644 mbn_stock/Form1.cs create mode 100644 mbn_stock/HeatMapRenderer.cs create mode 100644 mbn_stock/Program.cs create mode 100644 mbn_stock/Properties/AssemblyInfo.cs create mode 100644 mbn_stock/Properties/Resources.Designer.cs create mode 100644 mbn_stock/Properties/Resources.resx create mode 100644 mbn_stock/Properties/Settings.Designer.cs create mode 100644 mbn_stock/Properties/Settings.settings create mode 100644 mbn_stock/SectorHeatMapItem.cs create mode 100644 mbn_stock/mbn_stock.csproj diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6380db0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.vs/ + +[Bb]in/ +[Oo]bj/ + +*.user +*.suo +*.userosscache +*.sln.docstates + +TestResult*/ +*.log diff --git a/mbn_stock.sln b/mbn_stock.sln new file mode 100644 index 0000000..9051eec --- /dev/null +++ b/mbn_stock.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.35731.53 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "mbn_stock", "mbn_stock\mbn_stock.csproj", "{C3638A86-3DDE-40D7-B896-A58DFC53E372}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C3638A86-3DDE-40D7-B896-A58DFC53E372}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3638A86-3DDE-40D7-B896-A58DFC53E372}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3638A86-3DDE-40D7-B896-A58DFC53E372}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3638A86-3DDE-40D7-B896-A58DFC53E372}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {C7AE5942-0C77-477D-A577-5D5787EDFC50} + EndGlobalSection +EndGlobal diff --git a/mbn_stock/App.config b/mbn_stock/App.config new file mode 100644 index 0000000..56efbc7 --- /dev/null +++ b/mbn_stock/App.config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/mbn_stock/Form1.Designer.cs b/mbn_stock/Form1.Designer.cs new file mode 100644 index 0000000..b18f8aa --- /dev/null +++ b/mbn_stock/Form1.Designer.cs @@ -0,0 +1,41 @@ + +namespace mbn_stock +{ + partial class Form1 + { + /// + /// 필수 디자이너 변수입니다. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// 사용 중인 모든 리소스를 정리합니다. + /// + /// 관리되는 리소스를 삭제해야 하면 true이고, 그렇지 않으면 false입니다. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form 디자이너에서 생성한 코드 + + /// + /// 디자이너 지원에 필요한 메서드입니다. + /// 이 메서드의 내용을 코드 편집기로 수정하지 마세요. + /// + private void InitializeComponent() + { + this.components = new System.ComponentModel.Container(); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(1280, 760); + this.Text = "업종별 증시 이미지 생성기"; + } + + #endregion + } +} + diff --git a/mbn_stock/Form1.cs b/mbn_stock/Form1.cs new file mode 100644 index 0000000..418d478 --- /dev/null +++ b/mbn_stock/Form1.cs @@ -0,0 +1,348 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Drawing; +using System.Drawing.Imaging; +using System.Globalization; +using System.Linq; +using System.Windows.Forms; + +namespace mbn_stock +{ + public partial class Form1 : Form + { + private readonly BindingList _items = new BindingList(); + private readonly Random _random = new Random(); + + private DataGridView _grid; + private PictureBox _previewBox; + private Button _randomButton; + private Button _saveButton; + private Label _statusLabel; + private bool _loading; + + public Form1() + { + InitializeComponent(); + BuildUi(); + LoadDefaultItems(); + RandomizeValues(); + } + + protected override void OnFormClosed(FormClosedEventArgs e) + { + if (_previewBox != null && _previewBox.Image != null) + { + _previewBox.Image.Dispose(); + _previewBox.Image = null; + } + + base.OnFormClosed(e); + } + + private void BuildUi() + { + Font = new Font("Malgun Gothic", 9.5f, FontStyle.Regular, GraphicsUnit.Point); + BackColor = Color.FromArgb(245, 246, 248); + MinimumSize = new Size(1100, 700); + + SplitContainer splitContainer = new SplitContainer(); + splitContainer.Dock = DockStyle.Fill; + splitContainer.FixedPanel = FixedPanel.Panel1; + splitContainer.SplitterDistance = 500; + splitContainer.Panel1MinSize = 420; + splitContainer.Panel2MinSize = 520; + splitContainer.BackColor = Color.FromArgb(210, 214, 220); + + Panel leftPanel = new Panel(); + leftPanel.Dock = DockStyle.Fill; + leftPanel.Padding = new Padding(14); + leftPanel.BackColor = Color.White; + + FlowLayoutPanel toolbar = new FlowLayoutPanel(); + toolbar.Dock = DockStyle.Top; + toolbar.Height = 46; + toolbar.FlowDirection = FlowDirection.LeftToRight; + toolbar.WrapContents = false; + + _randomButton = new Button(); + _randomButton.Text = "랜덤 값"; + _randomButton.Width = 110; + _randomButton.Height = 32; + _randomButton.Click += RandomButton_Click; + + _saveButton = new Button(); + _saveButton.Text = "PNG 저장"; + _saveButton.Width = 110; + _saveButton.Height = 32; + _saveButton.Click += SaveButton_Click; + + toolbar.Controls.Add(_randomButton); + toolbar.Controls.Add(_saveButton); + + _grid = new DataGridView(); + _grid.Dock = DockStyle.Fill; + _grid.AutoGenerateColumns = false; + _grid.AllowUserToAddRows = false; + _grid.AllowUserToDeleteRows = false; + _grid.AllowUserToResizeRows = false; + _grid.RowHeadersVisible = false; + _grid.SelectionMode = DataGridViewSelectionMode.CellSelect; + _grid.MultiSelect = false; + _grid.BackgroundColor = Color.White; + _grid.BorderStyle = BorderStyle.FixedSingle; + _grid.EnableHeadersVisualStyles = false; + _grid.ColumnHeadersDefaultCellStyle.BackColor = Color.FromArgb(238, 241, 245); + _grid.ColumnHeadersDefaultCellStyle.ForeColor = Color.FromArgb(30, 35, 45); + _grid.ColumnHeadersDefaultCellStyle.Font = new Font(Font, FontStyle.Bold); + _grid.DefaultCellStyle.SelectionBackColor = Color.FromArgb(218, 232, 255); + _grid.DefaultCellStyle.SelectionForeColor = Color.FromArgb(20, 24, 32); + _grid.DataError += Grid_DataError; + _grid.CellEndEdit += Grid_CellEndEdit; + _grid.CellParsing += Grid_CellParsing; + _grid.CellValidating += Grid_CellValidating; + + DataGridViewTextBoxColumn nameColumn = new DataGridViewTextBoxColumn(); + nameColumn.DataPropertyName = "Name"; + nameColumn.HeaderText = "업종"; + nameColumn.ReadOnly = true; + nameColumn.AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill; + nameColumn.FillWeight = 130f; + + DataGridViewTextBoxColumn capColumn = new DataGridViewTextBoxColumn(); + capColumn.DataPropertyName = "MarketCap"; + capColumn.HeaderText = "시가총액"; + capColumn.AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill; + capColumn.FillWeight = 90f; + capColumn.DefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleRight; + capColumn.DefaultCellStyle.Format = "N0"; + + DataGridViewTextBoxColumn rateColumn = new DataGridViewTextBoxColumn(); + rateColumn.DataPropertyName = "ChangeRate"; + rateColumn.HeaderText = "등락률(%)"; + rateColumn.AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill; + rateColumn.FillWeight = 90f; + rateColumn.DefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleRight; + rateColumn.DefaultCellStyle.Format = "+0.00;-0.00;0.00"; + + _grid.Columns.Add(nameColumn); + _grid.Columns.Add(capColumn); + _grid.Columns.Add(rateColumn); + _grid.DataSource = _items; + + _statusLabel = new Label(); + _statusLabel.Dock = DockStyle.Bottom; + _statusLabel.Height = 30; + _statusLabel.TextAlign = ContentAlignment.MiddleLeft; + _statusLabel.ForeColor = Color.FromArgb(80, 86, 96); + + leftPanel.Controls.Add(_grid); + leftPanel.Controls.Add(toolbar); + leftPanel.Controls.Add(_statusLabel); + + Panel previewPanel = new Panel(); + previewPanel.Dock = DockStyle.Fill; + previewPanel.Padding = new Padding(14); + previewPanel.BackColor = Color.FromArgb(29, 32, 39); + + _previewBox = new PictureBox(); + _previewBox.Dock = DockStyle.Fill; + _previewBox.BackColor = Color.FromArgb(20, 22, 28); + _previewBox.SizeMode = PictureBoxSizeMode.Zoom; + + previewPanel.Controls.Add(_previewBox); + splitContainer.Panel1.Controls.Add(leftPanel); + splitContainer.Panel2.Controls.Add(previewPanel); + + Controls.Add(splitContainer); + } + + private void LoadDefaultItems() + { + _loading = true; + _items.Clear(); + + string[] sectors = + { + "전기전자", + "금융업", + "서비스업", + "운수장비", + "화학", + "통신업", + "의약품", + "철강금속", + "유통업", + "건설업", + "음식료품", + "기계", + "운수창고", + "전기가스업", + "보험", + "증권" + }; + + foreach (string sector in sectors) + { + _items.Add(new SectorHeatMapItem + { + Name = sector, + MarketCap = 100m, + ChangeRate = 0m + }); + } + + _loading = false; + } + + private void RandomizeValues() + { + _loading = true; + + foreach (SectorHeatMapItem item in _items) + { + item.MarketCap = _random.Next(25, 260); + item.ChangeRate = Math.Round((decimal)(_random.NextDouble() * 60d - 30d), 2); + } + + _loading = false; + _grid.Refresh(); + RefreshPreview(); + } + + private void RefreshPreview() + { + if (_loading || _previewBox == null) + { + return; + } + + List snapshot = _items + .Select(item => new SectorHeatMapItem + { + Name = item.Name, + MarketCap = item.MarketCap, + ChangeRate = item.ChangeRate + }) + .ToList(); + + Bitmap newImage = HeatMapRenderer.Render(snapshot); + Image oldImage = _previewBox.Image; + _previewBox.Image = newImage; + + if (oldImage != null) + { + oldImage.Dispose(); + } + + _statusLabel.Text = string.Format( + CultureInfo.CurrentCulture, + "출력 {0:N0} x {1:N0}px / 색상 기준 ±{2:N0}%", + HeatMapRenderer.ExportWidth, + HeatMapRenderer.ExportHeight, + HeatMapRenderer.MaxRatePercent); + } + + private void RandomButton_Click(object sender, EventArgs e) + { + RandomizeValues(); + } + + private void SaveButton_Click(object sender, EventArgs e) + { + if (!_grid.EndEdit()) + { + _statusLabel.Text = "입력값을 확인해 주세요."; + return; + } + + using (SaveFileDialog dialog = new SaveFileDialog()) + { + dialog.Filter = "PNG 이미지 (*.png)|*.png"; + dialog.FileName = "sector_heatmap_" + DateTime.Now.ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture) + ".png"; + dialog.Title = "업종별 증시 이미지 저장"; + + if (dialog.ShowDialog(this) != DialogResult.OK) + { + return; + } + + using (Bitmap bitmap = HeatMapRenderer.Render(_items)) + { + bitmap.Save(dialog.FileName, ImageFormat.Png); + } + + _statusLabel.Text = "저장 완료: " + dialog.FileName; + } + } + + private void Grid_CellEndEdit(object sender, DataGridViewCellEventArgs e) + { + RefreshPreview(); + } + + private void Grid_CellParsing(object sender, DataGridViewCellParsingEventArgs e) + { + if (_grid.Columns[e.ColumnIndex].DataPropertyName == "Name") + { + return; + } + + decimal value; + string text = Convert.ToString(e.Value, CultureInfo.CurrentCulture); + + if (TryParseDecimal(text, out value)) + { + e.Value = value; + e.ParsingApplied = true; + } + } + + private void Grid_CellValidating(object sender, DataGridViewCellValidatingEventArgs e) + { + if (_grid.Columns[e.ColumnIndex].DataPropertyName == "Name") + { + return; + } + + decimal value; + string text = Convert.ToString(e.FormattedValue, CultureInfo.CurrentCulture); + + if (!TryParseDecimal(text, out value)) + { + e.Cancel = true; + _grid.Rows[e.RowIndex].ErrorText = "숫자를 입력하세요."; + return; + } + + if (_grid.Columns[e.ColumnIndex].DataPropertyName == "MarketCap" && value <= 0m) + { + e.Cancel = true; + _grid.Rows[e.RowIndex].ErrorText = "시가총액은 0보다 커야 합니다."; + return; + } + + _grid.Rows[e.RowIndex].ErrorText = string.Empty; + } + + private void Grid_DataError(object sender, DataGridViewDataErrorEventArgs e) + { + e.ThrowException = false; + _statusLabel.Text = "입력값을 확인해 주세요."; + } + + private static bool TryParseDecimal(string text, out decimal value) + { + value = 0m; + + if (text == null) + { + return false; + } + + string normalized = text.Trim().Replace("%", string.Empty); + return decimal.TryParse(normalized, NumberStyles.Number, CultureInfo.CurrentCulture, out value) + || decimal.TryParse(normalized, NumberStyles.Number, CultureInfo.InvariantCulture, out value); + } + } +} diff --git a/mbn_stock/HeatMapRenderer.cs b/mbn_stock/HeatMapRenderer.cs new file mode 100644 index 0000000..708c3a1 --- /dev/null +++ b/mbn_stock/HeatMapRenderer.cs @@ -0,0 +1,332 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using System.Drawing.Text; +using System.Globalization; +using System.Linq; + +namespace mbn_stock +{ + public static class HeatMapRenderer + { + public const int ExportWidth = 1920; + public const int ExportHeight = 1080; + public const decimal MaxRatePercent = 30m; + + private const float TileGap = 6f; + private static readonly float TileRadius = 0f; + + public static Bitmap Render(IEnumerable sourceItems) + { + return Render(sourceItems, new Size(ExportWidth, ExportHeight)); + } + + public static Bitmap Render(IEnumerable sourceItems, Size size) + { + Bitmap bitmap = new Bitmap(size.Width, size.Height, PixelFormat.Format32bppArgb); + + using (Graphics graphics = Graphics.FromImage(bitmap)) + { + graphics.SmoothingMode = SmoothingMode.AntiAlias; + graphics.InterpolationMode = InterpolationMode.HighQualityBicubic; + graphics.PixelOffsetMode = PixelOffsetMode.HighQuality; + graphics.TextRenderingHint = TextRenderingHint.AntiAliasGridFit; + graphics.Clear(Color.FromArgb(20, 22, 28)); + + List items = sourceItems + .Where(item => item != null && item.MarketCap > 0m) + .OrderByDescending(item => item.MarketCap) + .ToList(); + + if (items.Count == 0) + { + DrawEmptyMessage(graphics, new RectangleF(0, 0, size.Width, size.Height)); + return bitmap; + } + + RectangleF canvas = new RectangleF(0, 0, size.Width, size.Height); + List nodes = BuildNodes(items, canvas); + DrawTiles(graphics, nodes); + } + + return bitmap; + } + + private static List BuildNodes(IList items, RectangleF bounds) + { + decimal total = items.Sum(item => item.MarketCap); + double totalArea = bounds.Width * bounds.Height; + List nodes = new List(); + + foreach (SectorHeatMapItem item in items) + { + double area = decimal.ToDouble(item.MarketCap / total) * totalArea; + nodes.Add(new TreeMapNode(item, area)); + } + + List result = new List(); + LayoutBinary(nodes, bounds, result); + return result; + } + + private static void LayoutBinary(IList nodes, RectangleF bounds, List result) + { + if (nodes.Count == 0) + { + return; + } + + if (nodes.Count == 1) + { + nodes[0].Bounds = bounds; + result.Add(nodes[0]); + return; + } + + double totalArea = nodes.Sum(node => node.Area); + int splitIndex = FindBalancedSplit(nodes, totalArea); + List firstGroup = nodes.Take(splitIndex).ToList(); + List secondGroup = nodes.Skip(splitIndex).ToList(); + double firstArea = firstGroup.Sum(node => node.Area); + float firstRatio = (float)(firstArea / totalArea); + + if (bounds.Width >= bounds.Height) + { + float firstWidth = bounds.Width * firstRatio; + RectangleF firstBounds = new RectangleF(bounds.X, bounds.Y, firstWidth, bounds.Height); + RectangleF secondBounds = new RectangleF(bounds.X + firstWidth, bounds.Y, bounds.Width - firstWidth, bounds.Height); + + LayoutBinary(firstGroup, firstBounds, result); + LayoutBinary(secondGroup, secondBounds, result); + return; + } + + float firstHeight = bounds.Height * firstRatio; + RectangleF topBounds = new RectangleF(bounds.X, bounds.Y, bounds.Width, firstHeight); + RectangleF bottomBounds = new RectangleF(bounds.X, bounds.Y + firstHeight, bounds.Width, bounds.Height - firstHeight); + + LayoutBinary(firstGroup, topBounds, result); + LayoutBinary(secondGroup, bottomBounds, result); + } + + private static int FindBalancedSplit(IList nodes, double totalArea) + { + double target = totalArea / 2d; + double running = 0d; + double bestDifference = double.MaxValue; + int splitIndex = 1; + + for (int index = 0; index < nodes.Count - 1; index++) + { + running += nodes[index].Area; + double difference = Math.Abs(target - running); + + if (difference <= bestDifference) + { + bestDifference = difference; + splitIndex = index + 1; + } + } + + return splitIndex; + } + + private static void DrawTiles(Graphics graphics, IList nodes) + { + foreach (TreeMapNode node in nodes) + { + RectangleF tileBounds = Inset(node.Bounds, TileGap / 2f); + if (tileBounds.Width <= 1f || tileBounds.Height <= 1f) + { + continue; + } + + Color fill = GetFillColor(node.Item.ChangeRate); + using (Brush brush = new SolidBrush(fill)) + using (Pen borderPen = new Pen(Color.FromArgb(70, 255, 255, 255), 1.5f)) + { + if (TileRadius <= 0f) + { + graphics.FillRectangle(brush, tileBounds); + graphics.DrawRectangle(borderPen, tileBounds.X, tileBounds.Y, tileBounds.Width, tileBounds.Height); + } + else + { + using (GraphicsPath path = CreateRoundedRectangle(tileBounds, TileRadius)) + { + graphics.FillPath(brush, path); + graphics.DrawPath(borderPen, path); + } + } + } + + DrawTileText(graphics, node.Item, tileBounds, fill); + } + } + + private static void DrawTileText(Graphics graphics, SectorHeatMapItem item, RectangleF tileBounds, Color fill) + { + RectangleF textBounds = Inset(tileBounds, Math.Max(10f, Math.Min(tileBounds.Width, tileBounds.Height) * 0.07f)); + if (textBounds.Width <= 8f || textBounds.Height <= 8f) + { + textBounds = Inset(tileBounds, 2f); + } + + Color textColor = GetReadableTextColor(fill); + string rateText = FormatRate(item.ChangeRate); + float fontSize = FindFittingFontSize(graphics, item.Name, rateText, textBounds); + float rateFontSize = Math.Max(8f, fontSize * 0.78f); + + using (Font nameFont = new Font("Malgun Gothic", fontSize, FontStyle.Bold, GraphicsUnit.Pixel)) + using (Font rateFont = new Font("Malgun Gothic", rateFontSize, FontStyle.Bold, GraphicsUnit.Pixel)) + using (Brush textBrush = new SolidBrush(textColor)) + using (StringFormat centerFormat = new StringFormat()) + { + centerFormat.Alignment = StringAlignment.Center; + centerFormat.LineAlignment = StringAlignment.Center; + centerFormat.Trimming = StringTrimming.EllipsisCharacter; + centerFormat.FormatFlags = StringFormatFlags.NoWrap; + + float nameHeight = nameFont.GetHeight(graphics); + float rateHeight = rateFont.GetHeight(graphics); + float totalHeight = nameHeight + rateHeight + Math.Max(2f, fontSize * 0.12f); + float top = textBounds.Y + (textBounds.Height - totalHeight) / 2f; + + RectangleF nameRect = new RectangleF(textBounds.X, top, textBounds.Width, nameHeight + 4f); + RectangleF rateRect = new RectangleF(textBounds.X, top + nameHeight + Math.Max(2f, fontSize * 0.12f), textBounds.Width, rateHeight + 4f); + + graphics.DrawString(item.Name, nameFont, textBrush, nameRect, centerFormat); + graphics.DrawString(rateText, rateFont, textBrush, rateRect, centerFormat); + } + } + + private static float FindFittingFontSize(Graphics graphics, string name, string rateText, RectangleF bounds) + { + float maxSize = Math.Min(96f, Math.Max(12f, Math.Min(bounds.Width * 0.18f, bounds.Height * 0.34f))); + + for (float size = maxSize; size >= 8f; size -= 1f) + { + using (Font nameFont = new Font("Malgun Gothic", size, FontStyle.Bold, GraphicsUnit.Pixel)) + using (Font rateFont = new Font("Malgun Gothic", Math.Max(8f, size * 0.78f), FontStyle.Bold, GraphicsUnit.Pixel)) + { + SizeF nameSize = graphics.MeasureString(name, nameFont); + SizeF rateSize = graphics.MeasureString(rateText, rateFont); + float totalHeight = nameFont.GetHeight(graphics) + rateFont.GetHeight(graphics) + Math.Max(2f, size * 0.12f); + + if (nameSize.Width <= bounds.Width * 0.96f && + rateSize.Width <= bounds.Width * 0.96f && + totalHeight <= bounds.Height * 0.92f) + { + return size; + } + } + } + + return 8f; + } + + private static Color GetFillColor(decimal changeRate) + { + double intensity = Math.Min(1d, Math.Abs(decimal.ToDouble(changeRate)) / decimal.ToDouble(MaxRatePercent)); + + if (changeRate > 0m) + { + return Interpolate(Color.FromArgb(255, 206, 206), Color.FromArgb(204, 0, 0), intensity); + } + + if (changeRate < 0m) + { + return Interpolate(Color.FromArgb(209, 225, 255), Color.FromArgb(0, 70, 190), intensity); + } + + return Color.FromArgb(226, 228, 232); + } + + private static Color Interpolate(Color from, Color to, double amount) + { + amount = Math.Max(0d, Math.Min(1d, amount)); + int r = (int)Math.Round(from.R + (to.R - from.R) * amount); + int g = (int)Math.Round(from.G + (to.G - from.G) * amount); + int b = (int)Math.Round(from.B + (to.B - from.B) * amount); + return Color.FromArgb(r, g, b); + } + + private static Color GetReadableTextColor(Color background) + { + double luminance = (0.299d * background.R) + (0.587d * background.G) + (0.114d * background.B); + return luminance < 145d ? Color.White : Color.FromArgb(24, 28, 36); + } + + private static string FormatRate(decimal value) + { + string format = value > 0m ? "+0.00'%'" + : value < 0m ? "0.00'%'" + : "0.00'%'"; + + return value.ToString(format, CultureInfo.InvariantCulture); + } + + private static RectangleF Inset(RectangleF rectangle, float padding) + { + float horizontal = Math.Min(padding, rectangle.Width / 2f - 0.5f); + float vertical = Math.Min(padding, rectangle.Height / 2f - 0.5f); + + if (horizontal < 0f) + { + horizontal = 0f; + } + + if (vertical < 0f) + { + vertical = 0f; + } + + return new RectangleF( + rectangle.X + horizontal, + rectangle.Y + vertical, + Math.Max(1f, rectangle.Width - horizontal * 2f), + Math.Max(1f, rectangle.Height - vertical * 2f)); + } + + private static GraphicsPath CreateRoundedRectangle(RectangleF bounds, float radius) + { + float diameter = radius * 2f; + GraphicsPath path = new GraphicsPath(); + path.AddArc(bounds.X, bounds.Y, diameter, diameter, 180, 90); + path.AddArc(bounds.Right - diameter, bounds.Y, diameter, diameter, 270, 90); + path.AddArc(bounds.Right - diameter, bounds.Bottom - diameter, diameter, diameter, 0, 90); + path.AddArc(bounds.X, bounds.Bottom - diameter, diameter, diameter, 90, 90); + path.CloseFigure(); + return path; + } + + private static void DrawEmptyMessage(Graphics graphics, RectangleF bounds) + { + using (Font font = new Font("Malgun Gothic", 40f, FontStyle.Bold, GraphicsUnit.Pixel)) + using (Brush brush = new SolidBrush(Color.White)) + using (StringFormat format = new StringFormat()) + { + format.Alignment = StringAlignment.Center; + format.LineAlignment = StringAlignment.Center; + graphics.DrawString("표시할 데이터가 없습니다.", font, brush, bounds, format); + } + } + + private sealed class TreeMapNode + { + public TreeMapNode(SectorHeatMapItem item, double area) + { + Item = item; + Area = area; + } + + public SectorHeatMapItem Item { get; private set; } + + public double Area { get; private set; } + + public RectangleF Bounds { get; set; } + } + } +} diff --git a/mbn_stock/Program.cs b/mbn_stock/Program.cs new file mode 100644 index 0000000..95849dc --- /dev/null +++ b/mbn_stock/Program.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace mbn_stock +{ + static class Program + { + /// + /// 해당 애플리케이션의 주 진입점입니다. + /// + [STAThread] + static void Main() + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.Run(new Form1()); + } + } +} diff --git a/mbn_stock/Properties/AssemblyInfo.cs b/mbn_stock/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..c09b5bd --- /dev/null +++ b/mbn_stock/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// 어셈블리에 대한 일반 정보는 다음 특성 집합을 통해 +// 제어됩니다. 어셈블리와 관련된 정보를 수정하려면 +// 이러한 특성 값을 변경하세요. +[assembly: AssemblyTitle("mbn_stock")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("mbn_stock")] +[assembly: AssemblyCopyright("Copyright © 2026")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// ComVisible을 false로 설정하면 이 어셈블리의 형식이 COM 구성 요소에 +// 표시되지 않습니다. COM에서 이 어셈블리의 형식에 액세스하려면 +// 해당 형식에 대해 ComVisible 특성을 true로 설정하세요. +[assembly: ComVisible(false)] + +// 이 프로젝트가 COM에 노출되는 경우 다음 GUID는 typelib의 ID를 나타냅니다. +[assembly: Guid("c3638a86-3dde-40d7-b896-a58dfc53e372")] + +// 어셈블리의 버전 정보는 다음 네 가지 값으로 구성됩니다. +// +// 주 버전 +// 부 버전 +// 빌드 번호 +// 수정 버전 +// +// 모든 값을 지정하거나 아래와 같이 '*'를 사용하여 빌드 번호 및 수정 번호를 +// 기본값으로 할 수 있습니다. +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/mbn_stock/Properties/Resources.Designer.cs b/mbn_stock/Properties/Resources.Designer.cs new file mode 100644 index 0000000..adef11b --- /dev/null +++ b/mbn_stock/Properties/Resources.Designer.cs @@ -0,0 +1,70 @@ +//------------------------------------------------------------------------------ +// +// 이 코드는 도구를 사용하여 생성되었습니다. +// 런타임 버전:4.0.30319.42000 +// +// 파일 내용을 변경하면 잘못된 동작이 발생할 수 있으며, 코드를 다시 생성하면 +// 이러한 변경 내용이 손실됩니다. +// +//------------------------------------------------------------------------------ + + +namespace mbn_stock.Properties +{ + /// + /// 지역화된 문자열 등을 찾기 위한 강력한 형식의 리소스 클래스입니다. + /// + // 이 클래스는 ResGen 또는 Visual Studio와 같은 도구를 통해 StronglyTypedResourceBuilder + // 클래스에서 자동으로 생성되었습니다. + // 멤버를 추가하거나 제거하려면 .ResX 파일을 편집한 다음 /str 옵션을 사용하여 + // ResGen을 다시 실행하거나 VS 프로젝트를 다시 빌드하십시오. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources + { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() + { + } + + /// + /// 이 클래스에서 사용하는 캐시된 ResourceManager 인스턴스를 반환합니다. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager + { + get + { + if ((resourceMan == null)) + { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("mbn_stock.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// 이 강력한 형식의 리소스 클래스를 사용하여 모든 리소스 조회에 대해 현재 스레드의 CurrentUICulture 속성을 + /// 재정의합니다. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture + { + get + { + return resourceCulture; + } + set + { + resourceCulture = value; + } + } + } +} diff --git a/mbn_stock/Properties/Resources.resx b/mbn_stock/Properties/Resources.resx new file mode 100644 index 0000000..af7dbeb --- /dev/null +++ b/mbn_stock/Properties/Resources.resx @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/mbn_stock/Properties/Settings.Designer.cs b/mbn_stock/Properties/Settings.Designer.cs new file mode 100644 index 0000000..a24203c --- /dev/null +++ b/mbn_stock/Properties/Settings.Designer.cs @@ -0,0 +1,29 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + + +namespace mbn_stock.Properties +{ + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "11.0.0.0")] + internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase + { + + private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); + + public static Settings Default + { + get + { + return defaultInstance; + } + } + } +} diff --git a/mbn_stock/Properties/Settings.settings b/mbn_stock/Properties/Settings.settings new file mode 100644 index 0000000..3964565 --- /dev/null +++ b/mbn_stock/Properties/Settings.settings @@ -0,0 +1,7 @@ + + + + + + + diff --git a/mbn_stock/SectorHeatMapItem.cs b/mbn_stock/SectorHeatMapItem.cs new file mode 100644 index 0000000..458abaf --- /dev/null +++ b/mbn_stock/SectorHeatMapItem.cs @@ -0,0 +1,67 @@ +using System.ComponentModel; + +namespace mbn_stock +{ + public class SectorHeatMapItem : INotifyPropertyChanged + { + private string _name; + private decimal _marketCap; + private decimal _changeRate; + + public event PropertyChangedEventHandler PropertyChanged; + + public string Name + { + get { return _name; } + set + { + if (_name == value) + { + return; + } + + _name = value; + OnPropertyChanged("Name"); + } + } + + public decimal MarketCap + { + get { return _marketCap; } + set + { + if (_marketCap == value) + { + return; + } + + _marketCap = value; + OnPropertyChanged("MarketCap"); + } + } + + public decimal ChangeRate + { + get { return _changeRate; } + set + { + if (_changeRate == value) + { + return; + } + + _changeRate = value; + OnPropertyChanged("ChangeRate"); + } + } + + private void OnPropertyChanged(string propertyName) + { + PropertyChangedEventHandler handler = PropertyChanged; + if (handler != null) + { + handler(this, new PropertyChangedEventArgs(propertyName)); + } + } + } +} diff --git a/mbn_stock/mbn_stock.csproj b/mbn_stock/mbn_stock.csproj new file mode 100644 index 0000000..5253a4d --- /dev/null +++ b/mbn_stock/mbn_stock.csproj @@ -0,0 +1,82 @@ + + + + + Debug + AnyCPU + {C3638A86-3DDE-40D7-B896-A58DFC53E372} + WinExe + mbn_stock + mbn_stock + v4.7.2 + 512 + true + true + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + + + + + + + + + Form + + + Form1.cs + + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + Designer + + + True + Resources.resx + + + SettingsSingleFileGenerator + Settings.Designer.cs + + + True + Settings.settings + True + + + + + + +